Compare commits

...
227 Commits
Author SHA1 Message Date
ChrisandClaude Opus 4.5 a0cac2174f chore: Bump version to 2.8.9-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 09:37:44 +00:00
ChrisandClaude Opus 4.5 056d1fb854 refactor: Move audiobook libraries toggle from settings to library menu
- Remove "Show only artists with albums" option from settings (now in library menu)
- Remove "Audiobook Libraries" section from settings screen
- Add ABS library filter to Books tab options menu
- Show spinner on menu button while syncing libraries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 09:33:27 +00:00
ChrisandClaude Opus 4.5 a4cc7115bf chore: Localization audit and debug cleanup
- Localize hardcoded strings in search_screen.dart (21 strings)
- Localize language names in settings_screen.dart
- Localize queue screen messages and tooltips
- Localize widget strings (queue_panel, debug_console)
- Add 15 new localization keys to app_en.arb
- Remove all debugPrint statements from widget files
  - expandable_player.dart (11 removed)
  - queue_panel.dart (8 removed)
  - player_selector.dart (2 removed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 08:58:31 +00:00
ChrisandClaude Opus 4.5 1f3188fa6c feat: Add player navigation to artist/album detail screens
- Tap artist name in expanded player to navigate to artist details
- Tap album name in expanded player to navigate to album details
- Use global navigator key for overlay context navigation
- Fix album details to use fresh album data for artist display
- Fix EmptyState widgets to be scrollable for PageView swipe detection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 08:36:50 +00:00
ChrisandClaude Opus 4.5 9527ba341e fix: Hide providers not synced to library from filter list
- Provider counts now only count items where inLibrary=true
  (the provider that actually added the item to library)
- Providers with 0 items are hidden from the filter list
- Fixes issue where playback-only providers appeared with
  duplicate counts matching other accounts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 07:56:47 +00:00
ChrisandClaude Opus 4.5 79e9e02f53 docs: Add session documentation for optimistic updates and podcast filtering
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 23:10:15 +00:00
ChrisandClaude Opus 4.5 d14a4b99c6 fix: Optimistic updates for library buttons and podcast provider filtering
- Add inLibrary field to ProviderMapping for accurate ownership tracking
- Fix podcast provider filtering to use inLibrary field
- Add itunes_podcasts to provider capabilities
- Convert album/artist/podcast detail screens to use optimistic updates
  (button updates immediately, reverts on API failure)
- Remove server-side provider filtering from search
- Add client-side provider filtering for search in-library mode
- Fix episode loading fallback for non-library podcasts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 23:08:11 +00:00
ChrisandClaude Opus 4.5 7e72b08f44 docs: Add detailed documentation for per-provider sync implementation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 10:26:35 +00:00
ChrisandClaude Opus 4.5 801952482f feat: Add per-provider sync with source tracking for instant filtering
- Add sourceProviders column to database schema (v6) for tracking which
  provider instance each item came from
- Update SyncService to sync each enabled provider separately and tag
  items with their source provider
- Add client-side filtering methods that use source tracking to properly
  differentiate between multiple accounts of the same provider type
  (e.g., two Spotify accounts)
- Update library UI to use instant client-side filtering with background
  sync for data freshness

This enables instant UI updates when toggling provider filters, with
accurate filtering even for same-provider-type accounts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 10:23:58 +00:00
ChrisandClaude Opus 4.5 bae1154bcf feat: Add server-side sorting for library items
- Add orderBy parameter to API methods (getArtists, getAlbums, getTracks,
  getPlaylists, getRadioStations, getPodcasts)
- Add orderBy parameter to provider load methods
- Migrate legacy sort values (alpha → name, alpha_desc → name_desc, etc.)
- Use MA API sort values: name, name_desc, year, year_desc, timestamp_added,
  timestamp_added_desc, last_played, play_count, artist_name, duration
- Trigger server-side reload when sort option changes
- Add options menu overlay management (closes on navigation)
- Trust server-side sorting instead of re-sorting client-side

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 00:50:48 +00:00
ChrisandClaude Opus 4.5 0342caaf5c chore: Bump version to 2.8.8-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 09:51:22 +00:00
Chris LaycockandClaude Opus 4.5 d85dc9e592 fix: Improve queue transfer behavior and dropdown styling
- Change transfer dropdown background to lighter shade of queue panel
  background instead of dark album highlight color
- Add forceCollapse() to cleanly collapse player after transfer
  (closes queue panel instantly + collapses in one motion)
- Track queue's player ID to detect player changes and reload correct queue
- Fix transfer dropdown to use selected player ID for correct filtering
- Clear source player's track cache after transfer to prevent stale data
  in player selector

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:33:50 +00:00
Your NameandClaude Opus 4.5 9338828ac7 feat: Add queue transfer between players
- Add transferQueue API method to move playback queue between players
- Add transfer queue button to queue screen with player picker
- Refactor queue panel with improved controls
- Minor library screen cleanup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 20:04:04 +00:00
Your NameandClaude Opus 4.5 d920abd241 fix: Player list swipe dismiss now matches tap/back behavior
- Remove separate _dragOffset variable that caused cards to "shoot out"
  below mini player during swipe dismiss
- Directly control animation controller during drag for seamless handoff
- Fix bounce timing when user drags past bounce threshold before release

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 06:07:33 +00:00
Your NameandClaude Opus 4.5 02f7d501ff fix: Handle keystore signature changes gracefully
- Add decryption error handling in SecureStorageService
- Clear corrupted credentials when BadPaddingException occurs
- Add android:appCategory="audio" to manifest for proper app categorization
- Fix letter scrollbar proportional scrolling and bottom padding

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 22:39:01 +00:00
Your NameandClaude Opus 4.5 5f72cca25e Fix author image fetching from Audnexus and Open Library
Two bugs were causing author photos to fail for well-known authors like
J.R.R. Tolkien and J.K. Rowling:

1. Audnexus API requires two-step fetch: The search endpoint only returns
   ASIN and name, not the image. Now correctly fetches author details by
   ASIN after search to get the image URL.

2. Open Library returns 302 redirects: The HEAD request check was only
   accepting 200 status codes, but Open Library redirects to archive.org.
   Now accepts 301/302/307/308 redirect responses as valid.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:00:23 +00:00
Your NameandClaude Opus 4.5 c2f75797e1 Fix hero animations for album grid views
Use consistent 256px image cache size for album grid cards to match
the detail screen cache size. This ensures smooth Hero animations
when navigating from grid view (both 2-column and 3-column) to the
album detail screen.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:05:20 +00:00
Your NameandClaude Opus 4.5 3d982892de Improve tracks tab layout in library
- Make track name the primary line (title) instead of artist
- Move artist to subtitle line
- Add track duration on the right side
- Keep favorite icon (now before duration) for favorited tracks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:44:56 +00:00
Your NameandClaude Opus 4.5 c48f9d19c4 Improve search screen UI layout
- Add magnifying glass icon inside search bar
- Move library toggle outside search bar with circle background
- Style library toggle like library screen buttons (44x44, CircleBorder)
- Toggle highlights with primary color when active

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:58:23 +00:00
Your NameandClaude Opus 4.5 e4f24766d2 Fix adaptive colors for library screen navigation
Add updateAdaptiveColorsFromImage calls before navigation to detail
screens in library tab. This ensures bottom nav colors update
immediately when navigating from:
- Artist tiles (list and grid views)
- Author navigation
- Audiobook/book navigation
- Series navigation (list and grid views)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:36:35 +00:00
Your NameandClaude Opus 4.5 d7ac8befac Add Wikidata source and fix author detail screen icon
1. Fix missing icon in author detail screen:
   - Use Stack pattern (icon underneath + image overlay) like library list
   - Ensures icon always visible when no image
   - Consistent Hero animation between screens

2. Add Wikidata as author image source:
   - Structured data from Wikipedia, better for exact person matching
   - Uses P18 (image) property to get Wikimedia Commons photos
   - Searches top 3 results to find one with an image
   - Added between Open Library and Wikipedia in priority

Source priority:
1. Audnexus (audiobook-focused)
2. Open Library (book database)
3. Wikidata (structured Wikipedia data) - NEW
4. Wikipedia (search-based fallback)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:16:56 +00:00
Your NameandClaude Opus 4.5 8a2a3b8a14 Make comma-separated author detection safer
- Check for common suffixes (Jr., Sr., III, PhD, etc.)
- Check if second part looks like initials (J.R.R., etc.)
- Require BOTH parts to have multiple words for multi-author split
- Don't cache null on failure - fall through to try full name

This prevents incorrectly splitting "Name, Jr." or "Lastname, First"
while still handling "Neil Gaiman, Dirk Maggs".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:01:41 +00:00
Your NameandClaude Opus 4.5 ea1133c4d6 Revert Wikipedia multi-query - caused worse results
The multiple search queries (exact name, then with suffixes) was
finding wrong pages. Reverting to original "name writer author"
search which was tuned to find author pages.

Keep the comma-separated author handling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:00:02 +00:00
Your NameandClaude Opus 4.5 cb2595e36a Improve author image lookup for combined names and Wikipedia
1. Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
   - Detects multi-author format vs "Lastname, Firstname" format
   - Tries to get image for each author until one succeeds

2. Improve Wikipedia search strategy
   - Try exact name first (works best for famous authors)
   - Then try with "author", "writer", "novelist" suffixes
   - This helps find authors whose pages might not rank for generic search

Should help find: Anne Rice, Philip Pullman, Paulo Coelho, P.G. Wodehouse

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:43:55 +00:00
Your NameandClaude Opus 4.5 70e983de51 Add periods to standalone single-letter initials
After normalizing "J. R.R Tolkien" to "J. R. R Tolkien", the final
"R" lacks a period and won't be detected as an initial.

Now adds periods to standalone uppercase letters between spaces:
"J. R. R Tolkien" -> "J. R. R. Tolkien"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:20:26 +00:00
Your NameandClaude Opus 4.5 251579d32a Handle inconsistent initial formatting like "J. R.R Tolkien"
The metadata sometimes has messy formats with inconsistent spacing
around initials. Now handles:
- "J. R.R Tolkien" -> "J. R. R. Tolkien"
- "J.R.R. Tolkien" -> "J. R. R. Tolkien"
- "J.K. Rowling" -> "J. K. Rowling"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:19:48 +00:00
Your NameandClaude Opus 4.5 9d5e8abc11 Improve author image lookup for names with initials
Problems:
- "J.R.R. Tolkien" wasn't being parsed correctly - the condensed
  initials "J.R.R." weren't detected as initials
- Wikipedia only tried the first name variation

Fixes:
1. Expand condensed initials: "J.R.R." -> "J. R. R." for better splitting
2. Better initial detection that handles multiple initials
3. Generate compact variations: "JRR Tolkien", "JK Rowling"
4. Wikipedia now tries top 3 variations (best source for famous authors)

Example variations for "J.R.R. Tolkien":
- J.R.R. Tolkien (original)
- J. R. R. Tolkien (expanded)
- Tolkien (last name only)
- JRR Tolkien (compact initials)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:18:29 +00:00
Your NameandClaude Opus 4.5 225a1bd010 Fix author image aspect ratio distortion
Remove memCacheHeight from author images - setting both memCacheWidth
and memCacheHeight to equal values forces the image to be cached as
a square in memory, which distorts non-square source images BEFORE
BoxFit.cover can properly scale and crop.

By only setting memCacheWidth, height scales proportionally to
preserve the original aspect ratio. BoxFit.cover then handles
filling the circular container correctly.

Fixed in:
- Library grid author cards
- Library list author tiles
- Author detail screen header

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:54:24 +00:00
Your NameandClaude Opus 4.5 ef5f6b6483 Fix author image sizing issues
1. Library grid: Wrap author image ClipOval in SizedBox for proper
   sizing constraints (prevents squished images on initial load)

2. Author detail screen: Use responsive sizing like artist detail:
   - coverSize = (screenWidth * 0.7).clamp(160, 280)
   - expandedHeight = coverSize + 100
   - Icon size scales with coverSize * 0.5
   - Added memCacheWidth/Height for better performance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:42:08 +00:00
Your NameandClaude Opus 4.5 4a369fa5f5 Update audit report with Phase 4 status
Completed:
- SettingsService generic helpers (~15% reduction)
- Removed unused providers (1,615 lines dead code)

Deferred (risk outweighs benefit):
- MusicAssistantProvider split - evolved with Sendspin, ID mapping
- Library screen tab extraction - tight state coupling
- API domain split - shared WebSocket connection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:26:32 +00:00
Your NameandClaude Opus 4.5 52489fa97a Remove unused LibraryProvider and ConnectionProvider
These providers were early attempts at splitting MusicAssistantProvider
that were never completed or integrated. Like PlayerProvider (removed in
previous commit), the code has evolved significantly and these files
would need substantial updates to be useful.

Also fixes stale comment reference to library_provider.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:19:20 +00:00
Your NameandClaude Opus 4.5 5c52ef2738 Remove unused PlayerProvider
The standalone PlayerProvider was created early but never integrated.
MusicAssistantProvider has since evolved with:
- Sendspin support (MA 2.7.0b20+)
- Cast-to-Sendspin ID mapping with DB persistence
- Position tracker integration
- Audio handler mode switching
- Complex player filtering

Keeping player code in MusicAssistantProvider as it's working
and would require ~1600 lines of sync work to update PlayerProvider.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:13:28 +00:00
Your Name 447b8c8a93 Fix nullable return types for view mode getters 2026-01-14 09:41:36 +00:00
Your NameandClaude Opus 4.5 eb34663d3f Phase 4: Extract SettingsService generic helpers
Added generic helper methods to reduce boilerplate:
- _getString(key, {defaultValue}) / _setString(key, value, {removeIfEmpty})
- _getBool(key, {defaultValue}) / _setBool(key, value)
- _getInt(key, {defaultValue}) / _setInt(key, value)
- _remove(key)

Refactored ~40 getter/setter pairs to use helpers:
- Theme settings (themeMode, materialTheme, adaptiveTheme, customColor)
- Locale, API keys, player settings
- Home screen row visibility settings (12 toggles)
- View mode settings

Reduced file from 1,031 to 874 lines (~15% reduction)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 09:36:40 +00:00
Your NameandClaude Opus 4.5 2d359db25c Update AUDIT_REPORT.md with comprehensive audit findings
Updated report with:
- Full codebase audit (104 Dart files, 18 screens)
- Critical/High/Medium severity categorization
- Refactoring roadmap (Phase 1-4)
- Quick wins list
- Files by priority

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 09:32:31 +00:00
Your NameandClaude Opus 4.5 0845d6d29a Phase 3: Replace Colors.red with StatusColors.favorite for favorites icons
Updated favorites heart icons to use semantic StatusColors.favorite:
- new_library_screen.dart: playlist and track favorites
- library_playlists_screen.dart: playlist favorites

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:29:10 +00:00
Your NameandClaude Opus 4.5 c80a86abe8 Phase 3: Replace hardcoded SizedBox gaps with Spacing tokens
Replaced const SizedBox(height: 8/16) with Spacing.vGap8/vGap16 in:
- album_details_screen.dart (7 replacements)
- artist_details_screen.dart (8 replacements)
- audiobook_detail_screen.dart (4 replacements)
- playlist_details_screen.dart (6 replacements)
- search_screen.dart (3 replacements)
- new_library_screen.dart (8 replacements)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:26:36 +00:00
Your NameandClaude Opus 4.5 8358274f73 Phase 3: Add design tokens and standardize values
Design tokens added:
- ButtonSizes: sm/md/lg/xl/xxl for touch targets, iconSm/iconMd/iconLg/iconXl
- StatusColors: success/error/warning/info with light/dark variants, favorite

Standardization:
- Library filter buttons now use ButtonSizes.md (36) and ButtonSizes.iconMd (18)
- Favorites button uses StatusColors.favorite instead of Colors.red
- All BorderRadius.circular(22) and circular(25) → Radii.xxl (24)

Files updated:
- album_details_screen, artist_details_screen, audiobook_detail_screen
- playlist_details_screen, search_screen, new_library_screen

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:25:27 +00:00
Your NameandClaude Opus 4.5 9200af705a Add 6px spacing between library filter buttons
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:06:17 +00:00
Your NameandClaude Opus 4.5 1eed678deb Fix Phase 2 build errors - undefined provider variables
- global_player_overlay.dart: Use isConnected/hasSelectedPlayer from
  context.select() instead of removed provider variable
- expandable_player.dart: Add themeProvider via context.read() for
  methods that need full ThemeProvider object

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:56:57 +00:00
Your NameandClaude Opus 4.5 4ab31c11df Adjust filter row button sizes to 36x36 with 18px icons
Reverts the oversized 44x44 buttons from Phase 1 audit fix to a more
balanced 36x36 size that fits better visually while still being
easily tappable.

- _filterRowHeight: 48 → 36
- buttonSize: 44 → 36
- iconSize: 20 → 18

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:53:42 +00:00
Your NameandClaude Opus 4.5 4c7ecfbf88 Optimize Provider selectors with context.select() (Phase 2 audit fixes)
- global_player_overlay: Use select() for isConnected/selectedPlayer check
- new_library_screen: Use select() for podcasts and radio tabs
- expandable_player: Use select() for adaptiveTheme boolean
- player_reveal_overlay: Remove unused ThemeProvider watch

Performance impact: Reduces unnecessary rebuilds when unrelated
provider state changes, improving scroll performance in library tabs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:34:04 +00:00
Your NameandClaude Opus 4.5 d376fd3725 Cache SharedPreferences and add migration skip flag (Phase 2 audit fixes)
- Add SettingsService.initialize() to cache SharedPreferences at startup
- Replace 130+ getInstance() calls with cached _getPrefs() helper
- Add migration completed flag to SecureStorageService
- Skip SecureStorage migration on subsequent app launches

Performance impact: Eliminates redundant SharedPreferences lookups
and saves ~100-300ms on startup after first run.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:32:17 +00:00
Your NameandClaude Opus 4.5 a7893e6ad9 Add navigation debounce and error boundaries (Phase 1 audit fixes)
- Add navigationDebounce timing constant (500ms)
- Convert card widgets to StatefulWidget for debounce state tracking
- Prevent double-tap navigation on AlbumCard, ArtistCard, PlaylistCard,
  PodcastCard, RadioStationCard
- Add FlutterError.onError handler for widget build errors
- Add PlatformDispatcher.onError for platform channel errors
- Wrap runApp in runZonedGuarded for async error catching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:30:45 +00:00
Your NameandClaude Opus 4.5 d3474a2665 Fix touch targets and bottom spacing (Phase 1 audit fixes)
- Increase filter row touch targets from 32x32 to 44x44 (Material Design minimum)
- Replace hardcoded bottom spacing (100, 140, 164) with BottomSpacing.withMiniPlayer
- Affected screens: album_details, artist_details, audiobook_detail,
  audiobook_author, playlist_details, podcast_detail, new_library

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:27:36 +00:00
Your Name 5bfa91c566 Revert "Increase library action buttons to 40x40 with 20px icons"
This reverts commit 884b457139.
2026-01-14 07:05:46 +00:00
Your Name 884b457139 Increase library action buttons to 40x40 with 20px icons
- Sort, favorites, and view mode buttons now larger and easier to tap
- Increased filter row height from 36px to 44px to accommodate
2026-01-14 06:58:46 +00:00
Your Name 5e2599bdb8 Fix author avatar icon visibility using Stack approach
- Icon is always rendered underneath in a Stack
- Image covers icon when loaded successfully
- No widget tree switching = no layout glitches
- Placeholder/error show SizedBox.shrink() to reveal icon beneath
2026-01-14 06:49:14 +00:00
Your NameandClaude Opus 4.5 4dec8f43a2 Fix author icon rendering by using builder function
Use a builder function to create fresh Icon widgets for each use
instead of reusing a single widget instance. Also wrap placeholder
and error widgets in properly sized Containers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:32:09 +00:00
Your NameandClaude Opus 4.5 ee7a08a859 Add fuzzy name matching for author image lookups
Name variations tried in order:
- Original name
- "Lastname, Firstname" -> "Firstname Lastname" conversion
- Remove titles (Dr., Mr., Mrs., Prof., Sir, Dame, etc.)
- Remove suffixes (Jr., Sr., III, PhD, etc.)
- First + Last name only (skip middle names/initials)
- Last name only (when first part is an initial)
- Handle double initials like "J.K. Rowling" -> try "Rowling"

Also refactored API calls into separate helper methods for cleaner code.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:15:39 +00:00
Your NameandClaude Opus 4.5 4351a32cde Fix author avatar fallback to use icon instead of text
- Use Icons.person_rounded for fallback instead of text initials
- Properly center the icon in both list tile and card views
- Fix sizing to prevent visual jumps when images load
- Simplify widget structure for consistent rendering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:11:38 +00:00
Your NameandClaude Opus 4.5 587115dc2b Fix tracks favorites, font size, and bottom padding
- Fix favorites toggle not showing tracks by using _sortedFavoriteTracks
  instead of filtering _sortedAllTracks (which has stale favorite flags)
- Fix tracks list font size to use titleMedium like other tabs
- Fix all tabs to use BottomSpacing.withMiniPlayer to account for
  mini player height, preventing bottom items from being cut off

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:59:19 +00:00
Your NameandClaude Opus 4.5 862c9d5ef3 Fix series folder detection for abbreviated paths
The browse API returns abbreviated paths like /s for series instead of
/series. Update detection to check for path.endsWith('/s') in addition
to checking for 'series' in the name or path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:44:26 +00:00
Your NameandClaude Opus 4.5 ed7613e6ad Fix audiobook bugs and UI inconsistencies
- Fix audiobook favorites toggle not reloading books when toggled
- Fix series loading by adding 'label' fallback for 'name' field
- Add consistent padding to View Mode button
- Add sort direction indicator with rotated icon for descending sorts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:29:52 +00:00
Your Name 74d2b1b1eb Increase filter chip padding to 12px 2026-01-13 21:57:05 +00:00
Your Name 951c8a68ea Fix highlight/content mismatch when swiping between media types
- Add flag to ignore onPageChanged during media type transitions
- Handle page jump directly in _changeMediaType instead of _resetCategoryIndex
- Clear flag after short delay to ensure all page change events have fired
2026-01-13 21:54:15 +00:00
Your Name c24dec51c5 Use TextPainter for accurate text measurement + fixed padding for consistent spacing 2026-01-13 21:48:28 +00:00
Your Name 11ed7e7d34 Fix swipe direction: go to last tab when swiping right-to-left between media types 2026-01-13 21:41:55 +00:00
Your Name 40f72f8db9 Fix inconsistent spacing in category chips - use same formula for flex and width 2026-01-13 21:38:28 +00:00
Your Name 5e2fb6ff32 Reduce filter row spacing and use consistent faded circle buttons 2026-01-13 21:29:26 +00:00
Your Name 47894e0bb9 Fix null safety for album artists sorting 2026-01-13 21:16:48 +00:00
Your NameandClaude Opus 4.5 c9f7321bec Add permanent Tracks tab, sort button, and extend favorites to all views
- Make Tracks tab always visible in Music (not just when favorites on)
- Add lazy loading for tracks (initial 200, +100 on scroll)
- Add sort button with popup menu next to favorites toggle
- Sort icon changes to reflect current sort order
- Extend favorites filtering to Radio and Podcasts tabs
- Add sort options for all categories:
  - Artists: A-Z, Z-A
  - Albums: A-Z, Z-A, Year (Newest/Oldest), By Artist
  - Tracks: A-Z, By Artist, By Album, Duration
  - Playlists: A-Z, Z-A, Track Count
  - Radio/Podcasts: A-Z, Z-A
- Persist sort preferences per category in settings
- Add localization strings for new empty states

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:07:27 +00:00
Your NameandClaude Opus 4.5 277020616f Bump version to 2.8.7-beta+45
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 16:07:45 +00:00
Your NameandClaude Opus 4.5 cd43ad0868 Fix queue sync issues and add error handling (issue #30)
- Subscribe to queue_items_updated events from Music Assistant
- Add error handling with rollback for queue delete operations
- Add error handling with rollback for queue move operations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 15:31:21 +00:00
Your NameandClaude Opus 4.5 98175719bf Add animated sliding highlight to search filter bar
- Add AnimatedPositioned highlight that slides smoothly between filters
- Match the library screen's animated pill-style highlight effect
- Use GlobalKeys to measure actual filter chip positions for accurate animation
- Fix scroll-back behavior by using measured positions instead of estimates
- The highlight animates with 300ms easeOutCubic curve matching library screen

Also fixes the scroll issue where swiping back to the beginning would leave
the last filter hidden. The scroll logic now uses actual measured positions
with proper padding instead of approximate 80px widths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:32:17 +00:00
Your NameandClaude Opus 4.5 e5274ca72e Fix favorite artists not showing on startup (issue #41)
Add listeners to SyncService and MusicAssistantProvider to detect when
artists become available after initial load. When the home screen builds
with empty favorites (before cache/sync completes), it now:

- Listens for SyncService status change from syncing to completed
- Listens for MusicAssistantProvider changes when artists load from cache
- Triggers a full row refresh when artists become available

This fixes the race condition where favorite rows would load before
the library cache was populated, showing empty results until manual refresh.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:46:37 +00:00
Your Name 6a78405dc1 Revert "Add long-press to favorite on PlaylistCard"
This reverts commit 49e8bbbb30.
2026-01-11 10:10:44 +00:00
Your NameandClaude Opus 4.5 49e8bbbb30 Add long-press to favorite on PlaylistCard
Quick enhancement from Issue #43:

- Convert PlaylistCard to StatefulWidget
- Add onLongPress handler to toggle favorite
- Show animated heart overlay with elastic animation
- Display persistent favorite indicator (heart badge) when favorited
- Haptic feedback on long-press
- Snackbar confirmation with offline sync support
- Invalidate home cache after favorite change

Matches the favorite functionality in PlaylistDetailsScreen.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:55:39 +00:00
Your NameandClaude Opus 4.5 beeebeb996 Fix language fallback to English and add French translation
Issue #44: Language now correctly falls back to English instead of German
for unsupported locales (e.g., French system language before this update).

Added localeListResolutionCallback to MaterialApp that:
- Respects user's explicit locale choice
- Falls back to matching system locale if available
- Defaults to English when no match found

Added complete French (fr) translation:
- All 200+ strings translated
- Proper French accents (é, è, ê, à, etc.)
- Added to settings language picker

Updated:
- lib/main.dart: localeListResolutionCallback for fallback logic
- lib/l10n/app_fr.arb: New French translation file
- lib/l10n/app_en.arb: Added "french" string
- lib/l10n/app_de.arb: Added "french" string (Französisch)
- lib/l10n/app_es.arb: Added "french" string (Francés)
- lib/providers/locale_provider.dart: Added French display name
- lib/screens/settings_screen.dart: Added French option

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:50:41 +00:00
Your NameandClaude Opus 4.5 49077b9858 Add hero animations to playlist displays across all screens
Applied optimized hero animation pattern from AlbumCard/ArtistAvatar:

Library Playlists Screen:
- Hero cover with ClipRRect(8) + CachedNetworkImage
- Hero title with Material wrapper
- heroTagSuffix: 'playlists'

New Library Screen (List):
- Hero cover + title with memCacheWidth/Height: 96
- heroTagSuffix: 'library_list'

New Library Screen (Grid):
- Hero cover + title with memCacheWidth/Height: 256
- RepaintBoundary wrapper added
- heroTagSuffix: 'library_grid'

Search Screen:
- Hero cover + title for search results
- heroTagSuffix: 'search'

Performance optimizations applied:
- fadeInDuration/fadeOutDuration: Duration.zero
- Matching memCacheWidth/Height between source and destination
- surfaceContainerHighest background for consistency
- placeholder: SizedBox() for smooth transitions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 01:12:18 +00:00
Your NameandClaude Opus 4.5 9dbcf9971f Upgrade PlaylistDetailsScreen to match AlbumDetailsScreen quality
Major improvements to the playlist detail screen:

- Add hero animations for playlist cover and title
- Add adaptive color extraction from cover art
- Add responsive layout with proper sizing (LayoutBuilder)
- Add action button row: Play, Play On, Add to Queue, Favorite
- Add expandable track actions on long-press (radio, queue, favorite)
- Replace hardcoded colors with theme-aware colors
- Add track duration display in track list
- Add fullscreen art viewer on cover tap
- Add proper bottom padding for mini player
- Update all playlist navigation to use FadeSlidePageRoute

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 01:00:22 +00:00
Your Name c5bcc509e0 Merge feature/home-row-conformity-and-favorite-podcasts: Row conformity fixes and favorite podcasts row 2026-01-11 00:09:56 +00:00
Your NameandClaude Opus 4.5 2f933da413 Remove all debug coloring from rows and home screen
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 00:02:46 +00:00
Your NameandClaude Opus 4.5 2fdd3b4286 Add blue debug color to content area of all rows
Debug colors now show every pixel:
- Green = title padding (12px top, 8px bottom)
- Yellow = 2px margin between rows
- Blue = content area (horizontal list)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:53:42 +00:00
Your NameandClaude Opus 4.5 779810674b Add red debug bar at end of content with calculated values
Shows availableHeight, rowHeight, enabledRows, and margins to help
identify what's causing scroll overflow.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:51:36 +00:00
Your NameandClaude Opus 4.5 0dc069b071 Fix 4th row peeking by only adjusting height for ≤3 rows
Only subtract margin space when there are 3 or fewer rows.
For 4+ rows, use original calculation so 4th row title stays hidden.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:49:29 +00:00
Your NameandClaude Opus 4.5 ca7b5d8d35 Fix scroll overflow by accounting for margins in row height calculation
The row height calculation now subtracts margin space before dividing
by 3, ensuring that 3 rows + 2 margins fit exactly without scroll.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:39:19 +00:00
Your NameandClaude Opus 4.5 b15de9775f Increase margin between rows from 1px to 2px
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:27:40 +00:00
Your NameandClaude Opus 4.5 5fcfe1d3b3 Add debug colors to visualize row layout
- Green: title padding area (12px top, 8px bottom)
- Yellow: 1px margin between rows

Temporary debug visualization to help understand spacing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:19:05 +00:00
Your NameandClaude Opus 4.5 6a8a4972dd Add 1px spacing between home screen rows
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:07:40 +00:00
Your Name 73a23594cf Revert "Increase row title bottom padding from 8px to 9px"
This reverts commit 02d7ea12cb.
2026-01-10 23:06:18 +00:00
Your NameandClaude Opus 4.5 02d7ea12cb Increase row title bottom padding from 8px to 9px
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:59:25 +00:00
Your NameandClaude Opus 4.5 601dd8ac7a Add favorite podcasts row and fix home row conformity
- Add PodcastCard and PodcastRow widgets for favorite podcasts
- Fix RadioStationRow to conform to established row patterns:
  - Use titleLarge font instead of titleMedium
  - Use fromLTRB padding (16, 12, 16, 8) instead of symmetric
  - Add SizedBox height wrapper, itemExtent, StretchScrollBehavior
  - Add DebugLogger for performance tracking
- Fix RadioStationCard text clipping (height: 1.15, SizedBox wrapper)
- Fix PlaylistRow with same conformity updates as RadioStationRow
- Add getFavoritePodcasts() to provider
- Add settings support for favorite podcasts row visibility
- Add localization strings for favorite podcasts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:46:02 +00:00
Your Name 6dbd0bbdfd Bump version to 2.8.6-beta+44 2026-01-09 21:38:25 +00:00
Your NameandClaude Opus 4.5 dedd1d2144 Fix podcast player: correct podcast name and text overlap
Two issues fixed:

1. Podcast name now displays correctly (not episode name twice)
   - Added _currentPodcastName context field to provider
   - Set podcast name when playing from podcast detail screen
   - Clear context on media type change, external source, or playback stop
   - Removed fallback to album/artist (which contained episode name)

2. Text overlap in mini player for long podcast titles
   - Changed maxLines from 2 to conditional (1 when collapsed, 2 when expanded)
   - Fixed trackInfoBlockHeight to account for podcasts hiding album line

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:24:59 +00:00
Your NameandClaude Opus 4.5 29eb7165c2 Fix highlight alignment at bar edges
First and last tabs are now flush with the container edges.
Middle tabs maintain 2px gap between adjacent highlights.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:30:07 +00:00
Your NameandClaude Opus 4.5 92333f8216 Filter bar uses same color as parent media type
- Moved color function to class level (_getMediaTypeColor)
- Category filter bar now uses the same color as the selected
  media type (e.g., purple for Music, orange for Audiobooks)
- Both bars share the same color logic

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:13:15 +00:00
Your NameandClaude Opus 4.5 1970bbf910 Add sliding animation to category filter bar
Same treatment as media type bar:
- Sliding highlight with 300ms easeOutCubic animation
- Full border radius pill shape
- 2px horizontal inset
- Proportional widths based on label length

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:05:15 +00:00
Your NameandClaude Opus 4.5 cbb5c68d5e Full height highlight with small horizontal inset only
- Highlight now fills full bar height (top: 0, bottom: 0)
- 2px horizontal inset for small gap between segments
- Border radius 8px to match container

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:01:01 +00:00
Your NameandClaude Opus 4.5 a7df388a5f Add sliding animation for library type tabs
- Highlight now slides smoothly between tabs (300ms, easeOutCubic)
- Pill-shaped highlight with full border radius on all sides
- 3px inset from container edges for visual separation
- Color also animates when switching types
- Uses LayoutBuilder to calculate proportional positions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 15:58:08 +00:00
Your NameandClaude Opus 4.5 b351cbd774 Fix swipe for single-category types (Podcasts, Radio)
PageView with only 1 item doesn't generate overscroll notifications.
For single-category types, use GestureDetector to detect horizontal
swipes and switch media types accordingly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 15:19:53 +00:00
Your NameandClaude Opus 4.5 05f09f76c9 Fix swipe direction for media type switching
Swapped the overscroll logic:
- Positive overscroll (swipe left at end) = go to NEXT type
- Negative overscroll (swipe right at start) = go to PREVIOUS type

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 15:09:18 +00:00
Your NameandClaude Opus 4.5 46ab36726a Add swipe between media types at category boundaries
When at the last category (e.g., Playlists in Music), swiping left
switches to the next media type (Audiobooks). Similarly, swiping
right at the first category goes to the previous media type.

Uses overscroll detection with 80px threshold to trigger the switch.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 14:53:08 +00:00
Your NameandClaude Opus 4.5 4643288ff8 Library type bar: muted colors based on primaryContainer tone
- Colors derived from primaryContainer using HSL hue shift
- Same saturation/lightness as filter bar, just different hues
- Text uses onPrimaryContainer for proper theme contrast
- Fixed vertical centering with alignment: Alignment.center

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:41:33 +00:00
Your NameandClaude Opus 4.5 e7c03c65a9 Theme-aware colors for library type bar
Dark theme: deeper colors with white text
Light theme: lighter pastels with dark text

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:33:02 +00:00
Your NameandClaude Opus 4.5 aff9cb1819 Match library type bar font size to filter bar (14px)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:27:31 +00:00
Your NameandClaude Opus 4.5 473f076b90 Fix library bar to fill full width with proportional flex
Use Expanded (not Flexible) so bar fills full width,
while flex values still give longer labels more space.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:24:41 +00:00
Your NameandClaude Opus 4.5 c009603302 Add pastel colors for each library type
- Music: soft purple
- Audiobooks: soft orange/amber
- Podcasts: soft teal
- Radio: soft pink

Colors appear when type is selected, dark text for contrast.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:20:15 +00:00
Your NameandClaude Opus 4.5 e89a4e26e5 Library type bar: proportional flex based on label length
- Bar fills full width with no gaps between segments
- Each segment gets flex = 10 (base) + label.length
- Longer labels like "Audiobooks" get more space than "Music"
- Works with any language - translations auto-adjust
- Icons always visible for all types
- Border radius matches filter bar (8px)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:16:43 +00:00
Your NameandClaude Opus 4.5 139ced9ae4 Redesign library type bar as unified segmented control
- All buttons now on one continuous bar with no gaps
- Icons always visible for each type (not just when selected)
- Smooth animation on selection change
- Better appearance on larger screens

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 13:04:53 +00:00
Your NameandClaude Opus 4.5 9030a2bb0d Remove debug logging from nav bar fix
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:24:06 +00:00
Your NameandClaude Opus 4.5 440a02e79f Fix nav bar using adaptive colors on home screen with collapsed player
The nav bar now correctly uses default surface color (grey) on the home screen
when the player is collapsed. Adaptive colors are only applied when:
1. The player is expanding/expanded (progress > 0)
2. The user is on a detail screen (album, artist, playlist)

This prevents favorites and radio stations from incorrectly tinting the nav bar
when played directly from the home screen.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:19:56 +00:00
Your Name fdd51af7db DEBUG: Red nav bar to verify fix is active 2026-01-09 12:11:41 +00:00
Your NameandClaude Opus 4.5 3bc8ef91c6 Fix nav bar using adaptive colors on home screen
- Only use adaptive colors for nav bar when:
  1. Player is expanding/expanded (progress > 0), OR
  2. On a detail screen (isOnDetailScreen flag)
- On home screen with collapsed player, always use default surface color
- Added isOnDetailScreen flag to ThemeProvider to distinguish between
  colors from detail screen navigation vs player color extraction
- Also applies to icon highlight color, not just background

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 11:56:46 +00:00
Your Name 4edcf858af Fix mini player: only lower primary row (9, 26, 46) 2026-01-09 10:10:07 +00:00
Your Name 2e274851d2 Adjust mini player text: shift down while preserving gaps 2026-01-09 10:07:20 +00:00
Your NameandClaude Opus 4.5 5d00705f91 Fix mini player text rows vertical centering
Adjust 3-line layout (track, artist, player) to be properly centered
within the 72px mini player height.

Before: Text block center at 32.5px (3.5px above true center)
After:  Text block center at 36px (true center)

Values changed:
- primaryTop: 5 → 9
- secondaryTop: 26 → 30
- tertiaryTop: 46 → 50

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 09:59:35 +00:00
Your NameandClaude Opus 4.5 71238981a3 Fix series cover hero animation showing wrong image
Remove flightShuttleBuilder that was showing only the first cover
during hero animation. The mosaic grid is simple enough (max 3x3)
to animate directly without performance issues.

Before: Single cover during flight -> mosaic snaps in at end
After:  Mosaic animates smoothly throughout the transition

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 09:33:21 +00:00
Your NameandClaude Opus 4.5 28d91956f1 Eliminate spinner flash by loading cache synchronously in initState
Move cached data loading from async method to initState so the first
build already has data. This prevents the brief spinner flash when
returning to the app after backgrounding.

Before: initState -> build (spinner) -> async cache load -> setState
After:  initState (sync cache) -> build (data) -> async fresh load

Applied to all row widgets:
- album_row.dart
- artist_row.dart
- playlist_row.dart
- radio_station_row.dart
- audiobook_row.dart
- series_row.dart
- track_row.dart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 08:49:12 +00:00
Your NameandClaude Opus 4.5 036d74a91e Fix TrackRow to use setState pattern for reconnection
Convert TrackRow from FutureBuilder to setState-based dual-loading
pattern (matching other row widgets). Add getCachedTracks callback
support for instant display and offline fallback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 07:05:02 +00:00
Your NameandClaude Opus 4.5 038fcccdd0 Fix home screen sections not rendering after reconnection (#41)
Convert AudiobookRow and SeriesRow from FutureBuilder to setState-based
dual-loading pattern (matching AlbumRow). Add caching support for
audiobooks and series to enable instant display and offline fallback.

Root cause: FutureBuilder pattern created Future once in initState.
When connection restored, widget State was preserved via KeepAlive,
_hasLoaded remained true, preventing data re-fetch.

Changes:
- AudiobookRow: setState pattern + getCachedAudiobooks callback
- SeriesRow: setState pattern + getCachedSeries callback
- CacheService: Add audiobook/series caching methods
- Provider: Add *WithCache() and getCached*() methods
- NewHomeScreen: Pass cache callbacks to audiobook/series rows

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 07:01:19 +00:00
Your NameandClaude Opus 4.5 df38f8e828 perf: Phase 5 - Cache animations and pre-compute lerp values
Major performance fixes for animation stutter:

1. Cache FadeTransition opacity animation
   - Created _fadeIn50to100 once in initState
   - Previously created new Tween/CurveTween/Animation objects every frame
   - Used by 3 FadeTransition widgets (audio format, progress bar, volume)

2. Pre-compute Alignment and FontWeight
   - textAlignment: computed once per frame, reused by 3 text elements
   - titleFontWeight: computed once per frame for title text
   - Removed unnecessary FontWeight.lerp from artist text

These changes eliminate ~6-9 object allocations per animation frame
that were happening in hot paths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:35:17 +00:00
Your Name 6d7e233b7d Merge branch 'feature/volume-control-redesign' into feature/curved-player-animations 2026-01-08 23:14:42 +00:00
Your NameandClaude Opus 4.5 8408db3abb perf: Optimize animations for butter-smooth 60-120fps
Phase 4 performance optimizations:

ExpandablePlayer:
- Cache MaterialRectCenterArcTween (recreate only on dimension change)
- Pre-cached static BoxShadow objects for mini player and art shadows
- Use Transform.translate for GPU-accelerated slide animations
- Pre-compute color opacities (textColor60, textColor70, textColor45)
- Use Color.lerp between pre-computed colors for artist text
- Throttle expansion progress notifications (notify on ≥1% change)
- Simplified shadow rendering with threshold-based visibility

PlayerRevealOverlay:
- Pre-compute player card data outside AnimatedBuilder
- Cache hint colors to avoid withOpacity() per frame
- Add _PlayerCardData helper class for pre-computed data

PlayerCard:
- Static const BoxShadow for card and volume overlay
- Already has RepaintBoundary (verified)

These changes reduce per-frame object allocations and move
slide animations to GPU compositing layer.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:14:14 +00:00
Your NameandClaude Opus 4.5 5cbca93a6d fix: Smooth shuffle/repeat button appearance to prevent control jerk
Previously shuffle and repeat buttons used conditional rendering
(if t > 0.5) which caused them to suddenly appear in the Row,
jerking the other playback controls.

Now the buttons:
- Always render with animated width (0 to 44px)
- Smoothly grow into place as animation progresses
- Fade in with expandedElementsOpacity after t > 0.3

This eliminates the visual jerk when expanding the player.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:13:50 +00:00
Your NameandClaude Opus 4.5 f7caf67f56 perf: Cache commonly used color opacities to reduce allocations
- Pre-compute textColor50, primaryColor20, primaryColor70 at method start
- Replace 8+ withOpacity() calls with cached color references
- Reduces Color object allocations during animation frames

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:02:42 +00:00
Your NameandClaude Opus 4.5 377d2a54cd perf: Optimize expandable player for 60-120fps animations
Phase 1: Cache static values
- Batch MediaQuery lookups into single InheritedWidget access
- Batch Theme lookups similarly
- Cache SliderThemeData as static const (avoid recreating every frame)

Phase 3: GPU optimizations
- Replace animated Material elevation with fixed elevation 0
- Use Container decoration with animated shadow opacity instead
- Fixed shadow blur/offset avoids expensive shadow recalculations

These optimizations reduce object allocations during animation frames
and avoid GPU-expensive elevation changes, improving frame timing
consistency for 60-120fps performance targets.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:59:46 +00:00
Your NameandClaude Opus 4.5 3b1a33d405 Implement proper Hero-like animations for player expansion
Major animation improvements:

1. Album art: Uses MaterialRectCenterArcTween for true curved arc path
   - Creates natural Hero-like trajectory instead of straight diagonal

2. Text alignment: Replaced discrete textAlign switches with Align.lerp
   - Title, artist, and player name now smoothly transition from left to center
   - No more jump at t=0.5 threshold

3. Text styles: Replaced discrete style switches with smooth lerping
   - FontWeight.lerp for smooth weight transitions
   - _lerpDouble for letterSpacing and lineHeight
   - Opacity lerping for color transitions

4. Player name: Staggered opacity fade (0 to 0.4 range)
   - Position animates smoothly throughout
   - Wraps in Align for smooth centering

5. Controls: Proper Align.lerp for centering
   - Removed hacky Padding approach
   - Smooth transition from right-aligned to centered

6. Removed broken _lerpX/_lerpY helpers
   - These were double-curving an already-curved t value

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:26:19 +00:00
Your NameandClaude Opus 4.5 62f36a0d8b Add curved path animations for player expand transition
Implement Hero-like curved animations for mini player to expanded player:
- Add _lerpX (easeOutQuad) for faster horizontal centering
- Add _lerpY (easeInOutCubic) for gradual vertical movement
- Album art now follows curved arc path instead of straight diagonal
- Title and artist text use curved paths for smoother transitions
- Controls use Align widget for smooth centering transition
- Player name now animates position (was previously static)

This creates more natural, hero-like transitions where elements
curve into their final positions rather than moving linearly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:02:36 +00:00
Your NameandClaude Opus 4.5 5addb922ad Fix indicator position to match Slider's internal thumb positioning
Flutter's Slider uses overlayRadius (20px) as padding on each side,
so the thumb moves within [overlayRadius, width - overlayRadius].
The indicator position calculation now accounts for this to stay
perfectly aligned with the thumb at all positions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:49:03 +00:00
Your NameandClaude Opus 4.5 e160d12075 Fix volume indicator to stay fixed with thumb at edges
Previously the indicator was clamped to stay 16px from edges, causing
it to animate separately from the thumb at 0% and 100%. Now it follows
the thumb exactly at all positions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:41:43 +00:00
Your NameandClaude Opus 4.5 c3c9ff4d4b Move precision hint above slider to avoid nav bar obstruction
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 18:10:44 +00:00
Your NameandClaude Opus 4.5 8b722ffdda Fix precision hint: use custom overlay styled like snackbar
ScaffoldMessenger couldn't find Scaffold from within expanded player overlay.
Using custom hint positioned below slider with snackbar-like styling:
- Dark background (#323232)
- White text
- Positioned below the volume slider

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 18:02:48 +00:00
Your NameandClaude Opus 4.5 e7996bcf78 Fix volume button stepping and use standard SnackBar for hint
- Fix consecutive button taps using stale volume (now uses pending volume)
- Both +/- buttons correctly step by 1
- Change precision mode hint to standard SnackBar for consistency
- SnackBar appears at bottom like other app messages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:54:00 +00:00
Your NameandClaude Opus 4.5 fb90502887 Show teardrop indicator on volume button taps
- Display teardrop indicator for 800ms when +/- buttons are tapped
- Both buttons already step by 1 (delta of -1 and +1)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:09:00 +00:00
Your NameandClaude Opus 4.5 d39727e585 Improve volume control: clamped indicator, adaptive color, precision hint
- Clamp teardrop indicator position to stay within slider bounds
- Use adaptive primary color instead of amber for precision mode
- Show "Precision mode" hint when show hints setting is enabled
- Auto-hide hint after 2 seconds
- Text color adapts based on background luminance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:59:47 +00:00
Your NameandClaude Opus 4.5 7ca9b557ee Add live volume updates, precision mode, and teardrop indicator
- Live volume: throttled updates during drag (150ms normal, 50ms precision)
- Precision mode: hold still 800ms for fine-grained control (10% range)
- Teardrop indicator: upside-down teardrop above thumb showing volume number
- Slider and track turn amber in precision mode
- Haptic feedback when entering precision mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:37:44 +00:00
Your NameandClaude Opus 4.5 a39a45bfa4 Redesign volume control: +/- buttons with floating indicator
- Replace mute button with volume decrease (-1%)
- Replace percentage text with volume increase (+1%)
- Add floating percentage indicator above thumb (visible only when dragging)
- Buttons adjust volume in steps of 1%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:27:45 +00:00
Your NameandClaude Opus 4.5 2669d05a00 Merge fix/queue-drag-reorder: Queue improvements and home screen fix
- Fix Android back gesture for queue panel (double haptic, reliability)
- Isolate queue animation to prevent full player rebuilds (perf)
- Increase spring stiffness for snappier queue animation
- Fix home screen row height calculation (no more scroll with 3 rows)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:23:40 +00:00
Your NameandClaude Opus 4.5 2636a09d85 Fix home screen row height calculation causing scroll
Row height now correctly divides available space by 3, removing
the erroneous +8 that caused unnecessary scroll with 3 rows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 15:48:12 +00:00
Your NameandClaude Opus 4.5 ba5d0566d1 PERF: Isolate queue animation to prevent full player rebuilds
Critical optimizations:
1. Remove _queuePanelAnimation from main AnimatedBuilder merge
   - Queue animation no longer rebuilds entire player (~150 widgets)
   - Only queue panel section rebuilds during slide animation

2. Add cached _queueSlideAnimation
   - Avoids recreating Tween.animate() on every frame
   - Animation object created once in initState

3. Wrap queue panel in own AnimatedBuilder
   - Uses child parameter to avoid rebuilding QueuePanel itself
   - Only Offstage wrapper updates during animation

4. Wrap favorite/queue buttons in own AnimatedBuilder
   - Button fade animation isolated from main player rebuilds
   - Returns SizedBox.shrink() when fully hidden

5. Increase spring stiffness for snappier animation
   - Open: 400→550 stiffness, 42→50 damping
   - Close: 450→600 stiffness, 45→52 damping

Expected improvement: ~60% reduction in frame time during queue animation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 15:25:15 +00:00
Your NameandClaude Opus 4.5 cfdef31274 Fix rapid open-close timing: use target state not animation value
Root cause: During rapid open-close, the animation value might not
have reached 0.5 yet when back is pressed, causing isQueuePanelOpen
to return false. Also, closeQueuePanel() rejected calls during animation.

Fix:
- Add isQueuePanelTargetOpen getter (intent, not animation value)
- Use target state in didPopRoute for reliable detection
- Allow closeQueuePanel to work during animation by stopping it first
- This handles rapid gestures without needing 3-second gaps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 15:02:26 +00:00
Your NameandClaude Opus 4.5 0124a955f0 Fix Android back: handle queue panel in didPopRoute
Root cause: didPopRoute in main.dart intercepts back gesture at app
level but only checked isPlayerExpanded, not isQueuePanelOpen. This
caused back to call collapsePlayer() which then called closeQueuePanel()
with default haptic enabled, resulting in double haptic (system + app).

Fix:
- Add isQueuePanelOpen check in didPopRoute BEFORE isPlayerExpanded
- Close queue panel with withHaptic: false (system provides haptic)
- Add withHaptic parameter to closeQueuePanel() and propagate through
  GlobalPlayerOverlay

This handles Android back at the app level, bypassing PopScope entirely
for reliable 100% interception.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:12:57 +00:00
Your NameandClaude Opus 4.5 9848399343 Fix PopScope not rebuilding when queue panel opens
Root cause: PopScope was outside AnimatedBuilder, so it never
rebuilt when _queuePanelTargetOpen changed. This caused canPop
to retain stale value (true) even when panel was open, allowing
Android back gesture to pass through instead of being intercepted.

Fix: Wrap _queuePanelTargetOpen changes in setState() to force
widget rebuild including PopScope with correct canPop value.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:01:45 +00:00
Your NameandClaude Opus 4.5 66f93c933f Fix Android back gesture failures with target state tracking
- Add _queuePanelTargetOpen boolean to track intended panel state
  separate from animation value, preventing race conditions where
  animation crosses 0.5 threshold during back gesture
- Set target state immediately when open/close is triggered
- Use target state for PopScope canPop check instead of animation value
- Add withHaptic parameter to _closeQueuePanelWithSpring() to avoid
  double haptic (system + app) on Android back gesture

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:51:11 +00:00
Your NameandClaude Opus 4.5 db995c5643 Add physical edge gesture absorbers to block Dismissible
Previous approaches using state tracking in confirmDismiss didn't
reliably block edge gestures from reaching Dismissible.

New approach: Add invisible GestureDetector overlays on left and
right edges of the queue list Stack. These physically absorb all
horizontal drag gestures from the edge zones before they can reach
the Dismissible widgets underneath.

The absorbers use HitTestBehavior.translucent so they don't block
taps, only horizontal drags.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:35:45 +00:00
Your NameandClaude Opus 4.5 a3af055688 Use static position tracking for edge zone detection
The Dismissible gesture system operates independently of the Listener.
Using static variables ensures the edge zone state persists correctly:

- Store last pointer down X position as static variable
- Add static getter lastTouchWasInEdgeZone for confirmDismiss
- Increased edge dead zone to 48px for better Android gesture coverage
- Edge touches now clear swipe state immediately

This ensures Dismissible's confirmDismiss can reliably check if the
gesture started from a screen edge, blocking accidental deletes
during Android back gesture.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:15:40 +00:00
Your NameandClaude Opus 4.5 29ea825a12 Fix Android back gesture edge cases
1. Prevent Dismissible from triggering on edge swipes:
   - Keep _startedInEdgeZone flag until next touch (not reset on pointer up)
   - Check flag in confirmDismiss to reject edge-initiated swipes
   - This prevents Android back from right edge triggering swipe-to-delete

2. Fix PopScope sometimes failing to close:
   - Remove strict isAnimating guard that could block close
   - Stop any existing animation and start close immediately
   - Always respond to back gesture when queue panel is open

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:06:10 +00:00
Your NameandClaude Opus 4.5 212a1e0818 Fix edge zone detection to use screen coordinates
Bug: Was checking local X within queue panel widget, but queue panel
slides in from right side of screen. Local x=0 is middle of screen,
not the screen edge where Android back gesture starts.

Fix: Use global event.position.dx (screen coordinates) to detect
if touch started near screen edges, not local widget coordinates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:41:43 +00:00
Your NameandClaude Opus 4.5 751d317f2a Fix double haptic on Android back gesture
Root cause: QueuePanel's Listener was detecting Android back gesture
(edge swipe) as a swipe-to-close, triggering haptic. PopScope also
handled it, causing double haptic.

Fix: Add edge dead zone (40px) to QueuePanel's swipe detection.
Swipes starting within the edge zone are ignored, letting only
PopScope handle the Android back gesture.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:35:56 +00:00
Your NameandClaude Opus 4.5 9b38f4fa3c Remove finger-following from queue swipe gesture
The direct value manipulation during swipe was causing jank from the
transition between finger tracking and spring animation. Simplified to:

- Swipe gesture just detects direction and velocity
- On release, decides to close or stay open based on velocity/displacement
- No intermediate finger tracking - cleaner animation trigger
- Panel either closes with spring or stays fully open

This eliminates the jank from mixed animation modes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:24:48 +00:00
Your NameandClaude Opus 4.5 07b2210a6a Fix swipe close jerk by removing CurvedAnimation distortion
Root cause: CurvedAnimation was transforming spring physics output,
causing discontinuity between finger tracking and spring animation.

During swipe:
- Controller value set to 0.6 (finger position)
- CurvedAnimation transforms: fastOutSlowIn(0.6) ≈ 0.78
- Panel shows at wrong position (not following finger 1:1)
- When spring starts, there's a jump to correct position → jerk

Fix: Use raw AnimationController directly instead of CurvedAnimation.
Spring physics already provide natural easing - applying another
curve on top distorts the motion and breaks finger tracking.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:16:20 +00:00
Your NameandClaude Opus 4.5 2e95cb6c95 Fix double close/haptic: remove competing gesture handler
Root cause: Two gesture handlers both triggered close on swipe right:
1. GestureDetector.onHorizontalDragEnd called _toggleQueuePanel()
2. QueuePanel's Listener called _closeQueuePanelWithSpring() via callback

Both fired on the same gesture, causing double close animation and
double haptic feedback.

Fix: Remove the redundant close trigger from GestureDetector since
QueuePanel's Listener already handles its own swipe-to-close.
Swipe left to OPEN queue is still handled by GestureDetector.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:49:05 +00:00
Your NameandClaude Opus 4.5 1e64a6bbce Fix queue panel double-close animation glitch
Root cause: The .then() callbacks that snapped controller value to
exactly 0/1 after spring animation caused a visible visual jump.

Changes:
- Remove all .then() snap callbacks from spring animations
- Use overdamped springs (damping > critical) to ensure clean settle
  without oscillation or need for manual snapping
- Open spring: stiffness=400, damping=42 (critical damping = 40)
- Close spring: stiffness=450, damping=45 for snappier close
- Snap-back spring uses same overdamped settings

The overdamped spring naturally settles at exactly 0/1 without
bouncing, eliminating the need for post-animation value correction.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:41:01 +00:00
Your NameandClaude Opus 4.5 fea33a9720 Fix queue panel only opening once after spring animation
Spring simulations don't always land exactly at 0/1, causing the toggle
check to fail. Fixed by:
- Using threshold check (< 0.1) instead of exact equality
- Snapping controller value to exact 0/1 when spring settles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:29:05 +00:00
Your NameandClaude Opus 4.5 7b372a7984 Improve queue panel animations and swipe gesture
- Use spring physics for smoother open/close animations
- Add haptic feedback on queue open, close, and swipe start
- Fix swipe-to-close gesture requiring 2 attempts:
  - Reduced horizontal detection tolerance (1.5x → 1.2x)
  - Added direction locking to prevent accidental cancellation
  - Use weighted velocity averaging for smoother detection
  - Consider total displacement for slow swipes
- Lowered velocity threshold (300 → 200) for easier close
- Increased position threshold (0.5 → 0.65) for more forgiving close

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:12:17 +00:00
Your NameandClaude Opus 4.5 2dcc6d5e24 Add responsive haptic feedback for queue drag reordering
- Medium impact on drag start (confirms grab)
- Selection click on each reorder (subtle feedback when items swap)
- Light impact on drop (confirms release)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 06:01:39 +00:00
Your NameandClaude Opus 4.5 460f7208eb Fix left side alignment - compensate margin on both sides
Current item has 8px margin for highlight effect.
Now compensate on BOTH left and right contentPadding:
- Left: Non-current 8+0+16=24px, Current 8+8+8=24px
- Right: Non-current 8+0+8+14=30px, Current 8+8+0+14=30px

Album art and icons now align consistently across all items.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:59:27 +00:00
Your NameandClaude Opus 4.5 a4c08cadc8 Fix right edge gap with conditional contentPadding
- Current item has 8px margin for highlight effect
- Non-current items have no margin
- Use contentPadding right=0 for current, right=8 for others
- This compensates so both have equal 30px gap from icon to edge
- Reduced from original 38px (non-current) and 46px (current)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:50:54 +00:00
Your NameandClaude Opus 4.5 36e5ee7afd Fix queue item trailing alignment properly
- Keep 48x48 touch target (Material Design guideline)
- Wrap play arrow in matching 48x48 SizedBox for visual alignment
- Remove duration right padding (48px box provides natural spacing)
- Both current track and other tracks now have consistent icon positioning

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:36:45 +00:00
Your NameandClaude Opus 4.5 a8bb34ff8c Reduce drag handle touch target width to close gap with duration
- Reverted contentPadding and alignment changes
- Reduced SizedBox width from 48 to 32 (kept height 48 for vertical touch area)
- This brings drag handle closer to duration while maintaining touch target

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:34:50 +00:00
Your NameandClaude Opus 4.5 2883899e75 Align drag handle icon to right edge of touch target
Changed Center to Align with centerRight alignment so the drag handle
icon aligns with the play arrow position on the current track.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:26:31 +00:00
Your NameandClaude Opus 4.5 3e91bbb10c Reduce gap between drag handles and right screen edge in queue panel
Set custom contentPadding on ListTile to reduce right padding from
default 16px to 8px, bringing drag handles closer to the edge.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:15:24 +00:00
Your Name b63d35bbe9 Revert "Move duration and drag handles to right side of queue items"
This reverts commit 8e4014bd56.
2026-01-08 05:14:43 +00:00
Your NameandClaude Opus 4.5 8e4014bd56 Move duration and drag handles to right side of queue items
- Add _formatDuration helper method for displaying track duration
- Move drag handle from leading to trailing position
- Display duration text next to drag handle
- Show play arrow icon for current item instead of drag handle

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:11:31 +00:00
Your NameandClaude Opus 4.5 dc6bdae5df Fix drag by reverting to Listener (bypasses gesture arena)
GestureDetector was losing to Dismissible in the gesture arena.
Listener uses raw pointer events and doesn't compete.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:02:37 +00:00
Your NameandClaude Opus 4.5 7bb1121867 Fix drag handle with explicit 48x48 touch target
Use SizedBox instead of Padding to ensure touch area isn't constrained

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:57:42 +00:00
Your NameandClaude Opus 4.5 57e1a9904f Fix drag handle touch area - reduce excessive padding
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:55:45 +00:00
Your NameandClaude Opus 4.5 de0290ce66 Add rounded corners to current track highlight in queue
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:46:43 +00:00
Your NameandClaude Opus 4.5 1e64673ffe Theme nav bar icons during player expansion when adaptive is on
- Add primaryColor to PlayerExpansionState
- Pass adaptive primary color from expanded player to nav bar
- Blend nav bar icon color during expansion animation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:23:34 +00:00
Your NameandClaude Opus 4.5 660c4d8388 Fix Material You and adaptive color theming issues
- Fix nav bar icon color to use Material You when enabled (was falling back to lilac)
- Fix stale adaptive colors hanging on when returning to home
- Only use adaptive colors when they actually exist

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:13:54 +00:00
Your NameandClaude Opus 4.5 00ccbf9e59 Improve drag handle touch target and remove refresh button
- Wrap drag handle in GestureDetector with larger padding (12h x 16v)
- Use HitTestBehavior.opaque for reliable touch detection
- Handle all pan events in GestureDetector for smoother dragging
- Remove redundant refresh button from header (queue syncs via WebSocket)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:46:04 +00:00
Your NameandClaude Opus 4.5 adc37c3320 Fix drag position when scrolled + add clear queue button
**Drag position fix:**
- Added GlobalKey to Stack for accurate position tracking
- Calculate overlay position relative to Stack, not screen
- Track touch offset within item for smooth finger following
- Works correctly when list is scrolled down

**Clear queue feature:**
- Added delete_sweep button in queue header
- Added queueCommandClear API method (player_queues/clear)
- Optimistic update clears list immediately

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:36:03 +00:00
Your NameandClaude Opus 4.5 a3f0a47b96 Comprehensive queue panel gesture refactor
Based on detailed audit, fixed dual gesture system conflict:

**Root cause:** Two gesture systems (GestureDetector + Listener) were
fighting to control the same animation controller, causing jitter and
stuck states.

**Solution:** Use ONLY the Listener approach (bypasses gesture arena):
- Removed GestureDetector horizontal drag handling for queue panel
- Removed unused state variables and methods
- Fixed Listener to handle direction changes (snap back when vertical)
- Added proper instantaneous velocity calculation
- Simplified animation callbacks in expandable_player

**Other cleanup:**
- Removed dead code (_listKey, _itemListsEqual, _buildAnimatedItem)
- Fixed _pendingReorder timer stacking with cancellable Timer
- Proper state cleanup in _startDrag and _resetSwipeState

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:20:23 +00:00
Your NameandClaude Opus 4.5 cd5bef04c2 Fix swipe-to-close gesture on queue track items
- Add swipe callbacks to QueuePanel (onSwipeStart, onSwipeUpdate, onSwipeEnd)
- Use Listener for raw pointer events to bypass gesture arena
- Wire callbacks to expandable_player's queue panel animation controller
- Swipe-to-close now works on tracks, not just header

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:00:51 +00:00
Your NameandClaude Opus 4.5 bd3be91095 Add optimistic update for tap-to-skip in queue
- Add _optimisticCurrentIndex state to show tapped track as current immediately
- Update _buildQueueList to use optimistic index when set
- Clear optimistic state when server catches up or after timeout
- Provides instant visual feedback when user taps a track

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:54:08 +00:00
Your NameandClaude Opus 4.5 5c7970029c Add gesture-driven queue panel close (follow thumb)
- Add state variables for queue panel horizontal drag tracking
- Create _handleQueuePanelDragStart/Update/End/Cancel methods
- Wire up horizontal drag handlers when queue panel is open
- Remove redundant swipe-to-close from QueuePanel itself
- Queue panel now follows finger when swiping right to close

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:51:53 +00:00
Your Name 2721e83cbf feat: tap track in queue to skip to that track
- Add queueCommandPlayIndex API method
- Call it on tap (except for currently playing track)
2026-01-07 20:37:41 +00:00
Your Name 1b669aecec fix: don't sync on order changes - trust local order after reorder
Only sync queue items when player changes or item count changes.
This prevents stale server state from flashing before correct order loads.
2026-01-07 20:26:18 +00:00
Your Name 05f44a6cc0 fix: use pos_shift (relative) instead of new_index (absolute) for queue move API
MA API expects pos_shift: positive=down, negative=up, 0=next
2026-01-07 20:03:10 +00:00
Your Name 999e56d116 fix: increase pendingReorder delay to 2s for server propagation 2026-01-07 19:27:57 +00:00
Your Name ef68252ba5 fix: correct API parameter name for queue move (item_id_or_index -> queue_item_id) 2026-01-07 19:19:01 +00:00
Your Name 7d47fa18f5 debug: add logging to queue move API call 2026-01-07 19:07:45 +00:00
Your Name 9542cdb7d7 fix: preserve queue state when panel closes and block all gestures during drag
- Block onVerticalDragStart/Update/End when _isQueueDragging
- Use Offstage instead of conditional rendering to preserve QueuePanel state
- QueuePanel stays mounted when hidden, keeping _pendingReorder and _items
2026-01-07 18:57:07 +00:00
Your Name eb8225b440 fix: prevent queue panel collapse while dragging track
- Add onDraggingChanged callback to QueuePanel
- Block swipe-to-close in expandable_player while queue is dragging
- Add _pendingReorder to prevent state revert after API call
2026-01-07 18:47:18 +00:00
Your Name 7d558ea135 fix: use parent Listener to track drag pointer globally
- Listener on drag handle only starts drag
- Listener wrapping Stack captures move/up/cancel events
- This prevents gesture loss when finger moves off the small drag handle
2026-01-07 18:35:30 +00:00
Your NameandClaude Opus 4.5 b19acdadc4 fix: disable list scrolling while dragging to prevent gesture conflict
The ListView was stealing the drag gesture after a brief moment,
causing the dragged item to freeze in place.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:06:35 +00:00
Your NameandClaude Opus 4.5 4577242e0f fix: use Stack-based overlay instead of OverlayEntry for drag
Replace OverlayEntry with Stack+Positioned within the widget tree.
This should fix the drag overlay not following the finger properly.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:44:39 +00:00
Your NameandClaude Opus 4.5 58e8265d2a fix: Improve drag tracking to follow finger smoothly
- Track absolute finger position instead of deltas
- Calculate target index from total offset
- Prevent didUpdateWidget from reverting during drag
- Fix cancel to restore original position

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:24:30 +00:00
Your NameandClaude Opus 4.5 4a9923d431 refactor: Replace great_list_view with custom drag-reorder implementation
Remove great_list_view package (alpha, abandoned, drag bug) and implement
custom drag-to-reorder using Overlay for dragged item. Uses ListView.builder
with manual drag state management.

Avoids:
- ReorderableListView (grey screen bug)
- great_list_view (drag duplication bug)

Features preserved:
- Swipe left to delete (Dismissible)
- Drag handle for reorder
- Right swipe to close panel

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:10:54 +00:00
Your NameandClaude Opus 4.5 d81a3ba3a5 docs: Comprehensive README update
Add documentation for all features since v2.0.0:
- Podcasts and Radio Stations
- Multi-room grouping
- Volume precision mode
- Queue improvements (drag/swipe)
- Search enhancements (fuzzy, history, quick actions)
- Letter scrollbar
- HA add-on setup instructions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 15:07:02 +00:00
Your Name 1bc35a9a16 Revert "docs: Add Home Assistant add-on setup instructions"
This reverts commit dd738120f1.
2026-01-07 12:49:31 +00:00
Your NameandClaude Opus 4.5 dd738120f1 docs: Add Home Assistant add-on setup instructions
Clarify that users need the Music Assistant URL (port 8095), not the
Home Assistant URL. Add instructions for finding Base URL and notes
about remote access setup.

Closes #31

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:48:08 +00:00
Your NameandClaude Opus 4.5 f26548cc17 docs: Update screenshots
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:43:15 +00:00
Your NameandClaude Opus 4.5 1bb6a49e22 feat: Faster volume updates in precision mode (50ms vs 150ms)
Precision mode now sends volume updates 3x faster for smoother control.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 10:40:44 +00:00
Your NameandClaude Opus 4.5 90f004ea47 chore: Bump version to 2.8.5-beta+43
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 10:00:33 +00:00
Your NameandClaude Opus 4.5 f24c98102c refactor: Replace visual feedback with haptic vibration for precision mode
Remove darkening and desaturation visual effects from precision volume control,
replacing with simple haptic feedback when entering precision mode. Cleaner UX
that doesn't obscure the UI while still providing clear tactile confirmation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 09:44:42 +00:00
Your Name 31a9f24d66 fix: Increase precision mode visual feedback to 50% darkness and desaturation 2026-01-07 09:29:59 +00:00
Your Name 29f2ff707e fix: Use correct API method to play radio stations 2026-01-07 08:47:21 +00:00
Your NameandClaude Opus 4.5 318a095c15 feat: Add optional Favorite Playlists and Favorite Radio Stations rows
- Added PlaylistCard and PlaylistRow widgets with hero animations
- Added RadioStationCard and RadioStationRow widgets with hero animations
- Added hero tags for playlists and radio stations
- Added settings to show/hide the new rows on home screen
- Added provider methods getFavoritePlaylists() and getFavoriteRadioStations()
- Added localization strings for the new rows
- Radio stations use circular images like artists
- Playlists use square images like albums

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 08:39:58 +00:00
Your Name 1796829a77 fix: Reduce darkness to 30% and desaturation to 25% for subtler effect 2026-01-07 08:31:28 +00:00
Your Name 96e6522a72 fix: Reduce desaturation to 50% for subtler effect 2026-01-07 08:22:46 +00:00
Your NameandClaude Opus 4.5 cde13864a3 feat: Darken and desaturate UI when in precision mode
- Mini player now darkens/desaturates when player list card is in precision mode
- Player list cards darken/desaturate when another card or mini player is in precision mode
- Content behind mini player darkens when in precision mode (during reveal)
- Uses animated saturation matrix for smooth grayscale transition

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 08:13:48 +00:00
Your NameandClaude Opus 4.5 ea5beec889 fix: Use offset-based movement for precision mode
Previous implementation mapped absolute finger position to zoomed range,
causing a jump when entering precision mode. Now uses offset from entry
point:

- Records finger X position when entering precision mode
- Movement FROM that position maps to the zoomed range
- Full card width of movement = 10% volume change
- No jump when entering precision mode

Example at 40%:
- Hold still → enter precision mode, finger at position X
- Move full card width RIGHT → volume goes to 50%
- Move full card width LEFT → volume goes to 30%

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 07:51:13 +00:00
Your NameandClaude Opus 4.5 b75152d4ad fix: Improve volume precision mode with zoom range and dark overlay
Issues fixed:
1. Precision mode now uses zoomed range mapping instead of sensitivity
   - At 40% volume, left edge = 35%, right edge = 45% (10% range)
   - Full card width control maintained, just zoomed into smaller range

2. Visual feedback now darkens instead of reducing opacity
   - Black overlay (60% opacity) instead of reducing card opacity to 30%
   - Proper darkening effect as requested

3. Mini player precision mode now darkens all player cards
   - Added callback from ExpandablePlayer to GlobalPlayerOverlay
   - GlobalPlayerOverlay passes state to PlayerRevealOverlay
   - All cards darken when mini player enters precision mode

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 07:41:08 +00:00
Your NameandClaude Opus 4.5 970644493e feat: Add volume precision mode for fine-grained control
Hold still while adjusting volume to enter precision mode (10x more
precise). This helps with low-volume fine-tuning like audiobooks at 3%.

Features:
- Hold still for 800ms during volume drag to enter precision mode
- 10x sensitivity reduction (full swipe = 10% change instead of 100%)
- Other player cards dim when one is in precision mode (spotlight effect)
- New setting to enable/disable precision mode (Settings > Players)
- Works on both player cards and mini player volume swipe

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 07:20:06 +00:00
Your NameandClaude Opus 4.5 80996e3e82 fix: light theme improvements
- Status bar icons now use dark icons in light theme (main.dart)
- Expanded player text/primary color fallbacks use colorScheme instead of white
- Placeholder art uses theme-aware colors
- Settings logo uses dark color filter in light theme

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:18:28 +00:00
Your NameandClaude Opus 4.5 9584f806ee fix: nav bar uses scheme.surface to match expanded player
Expanded player uses adaptiveScheme.surface for its background.
Nav bar was incorrectly using primaryContainer or HSL manipulation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:07:15 +00:00
Your NameandClaude Opus 4.5 e2f420df0c fix: nav bar uses subtle tint instead of primaryContainer
- Dark mode: lightness 0.10, saturation 0.12 (very dark)
- Light mode: lightness 0.98, saturation 0.04 (nearly white)

Nav bar should be more neutral than player card.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:01:59 +00:00
Your NameandClaude Opus 4.5 11faba74ea debug: add volume swipe logging for diagnosis
Logs drag start/end with key state values:
- useLocalVolume decision
- hasOverride flag
- time since last drag
- local vs player volume

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:56:00 +00:00
Your NameandClaude Opus 4.5 6459f8bf87 fix: nav bar uses same adaptive color as expanded player
Use primaryContainer from appropriate light/dark scheme instead
of custom HSL manipulation. This matches how the expanded player
gets its background color, ensuring consistency.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:53:52 +00:00
Your NameandClaude Opus 4.5 cd1ab6e9f7 Add player animation audit prompt
Comprehensive audit prompt for polishing the player system animations
including mini player, expanded player, queue panel, and player selector.
Uses ultrathink and sub-agents for thorough analysis.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:17:43 +00:00
Your NameandClaude Opus 4.5 8e5bfa3657 Add defensive queue check in collapse() method
Belt-and-suspenders fix: collapse() now checks if queue panel is open
and closes it instead of collapsing the player. This ensures queue
always closes first regardless of how collapse() is called.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:06:50 +00:00
Your NameandClaude Opus 4.5 01ea21917a Fix Android back button to close queue panel before collapsing player
The back button handling in home_screen.dart was checking for expanded
player but not for queue panel. Since home_screen's PopScope intercepts
all back presses (canPop: false), the expandable_player's PopScope was
never reached.

Added:
- closeQueuePanel() method to ExpandablePlayerState
- isQueuePanelOpen and closeQueuePanel() static accessors to GlobalPlayerOverlay
- Queue panel check in home_screen.dart back button handler (before player check)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:58:35 +00:00
Your NameandClaude Opus 4.5 236698f1b4 Fix queue panel navigation and back button handling
- Use raw Listener instead of GestureDetector for right-swipe-to-close
  detection on the entire queue panel. This bypasses the gesture arena
  so Dismissible widgets don't block the gesture.
- Update PopScope to handle both queue panel and expanded player:
  back button now closes queue first, then collapses player, then exits.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:48:17 +00:00
Your NameandClaude Opus 4.5 0699523914 Add Android back + swipe right navigation for queue
- PopScope in expandable_player: back button closes queue first
- GestureDetector in queue_panel: swipe right closes queue

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:30:31 +00:00
Your NameandClaude Opus 4.5 9ebd49d7b3 Don't update _items on delete - let Dismissible handle visual
Removing from _items causes AutomaticAnimatedListView to also animate
the removal, conflicting with Dismissible's animation. Let Dismissible
handle the visual removal alone, API updates server.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:20:43 +00:00
Your NameandClaude Opus 4.5 ab734ac996 Use consistent widget structure for measuring
Always wrap in Dismissible even during measuring phase to maintain
consistent widget tree structure. Only vary image loading based on
measuring flag.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:13:58 +00:00
Your NameandClaude Opus 4.5 88fe49d506 Fix didUpdateWidget - only sync on queue/player change
Previously, didUpdateWidget reset _items on every parent rebuild
(reference comparison always true). Now only syncs when switching
to a different player's queue, preserving local state for animations.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:00:43 +00:00
Your NameandClaude Opus 4.5 b497a44930 Fix delete animation - don't refresh after API call
Remove onRefresh() after delete to prevent stale data from
bringing back the deleted item (ghost item bug). Local removal
is sufficient - next natural refresh will confirm deletion.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:52:48 +00:00
Your NameandClaude Opus 4.5 1324b23167 Fix drag handle - use Builder context like overlay branch
- Use Builder to get itemContext for notifyStartReorder
- Pass localPosition instead of delta for updates
- Remove unused _draggingIndex state

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:43:33 +00:00
Your NameandClaude Opus 4.5 c9eb3fb7ec Add instant drag handles (no long press required)
- Use GestureDetector on drag handle icon
- Call controller.notifyStartReorder/Update/Stop on drag events
- Set addLongPressReorderable: false

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:42:29 +00:00
Your NameandClaude Opus 4.5 84bfcf7e06 Fix delete animation bug - remove by ID not index
- Use removeWhere with queueItemId instead of removeAt(index)
- Prevents wrong track being removed due to index mismatch

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:41:36 +00:00
Your NameandClaude Opus 4.5 29167dcdf6 Add init.gradle to force Alibaba mirrors + Gradle caching
- Add init.gradle that removes Maven Central and forces Alibaba mirrors
- Add Gradle cache step to workflow
- Copy init.gradle to ~/.gradle before build

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:25:16 +00:00
Your NameandClaude Opus 4.5 01aca2e27c Fix card colors and delete animation
- Set card background to match expanded player background
- Use zero border radius to cover grey from list package
- Remove item from local list immediately on dismiss to prevent
  double animation (Dismissible + AutomaticAnimatedListView)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:18:12 +00:00
Your NameandClaude Opus 4.5 26fcd90557 Add Gradle cache clear step to force mirror usage
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:06:21 +00:00
Your NameandClaude Opus 4.5 129a1c8b28 Fix build errors in queue_panel.dart
- Use playerId instead of queueId (PlayerQueue property)
- Fix _formatDuration to accept Duration? type
- Remove dispose() call (AnimatedListController doesn't have it)
- Remove unsupported reorderableOptions parameter

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:59:19 +00:00
Your NameandClaude Opus 4.5 03f96537eb Add Alibaba Maven mirrors for faster builds
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:53:47 +00:00
Your NameandClaude Opus 4.5 405816939d Add great_list_view queue with drag-reorder and swipe-to-delete
- Convert QueuePanel to StatefulWidget with AutomaticAnimatedListView
- Add long-press drag-to-reorder functionality
- Add swipe-left-to-delete (endToStart only)
- Add queueCommandDeleteItem and queueCommandMoveItem API methods
- Show drag handle icon and track durations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:47:12 +00:00
Your NameandClaude Opus 4.5 6661375f99 chore: bump version to 2.8.4-beta+42
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:18:30 +00:00
Your NameandClaude Opus 4.5 d6e759f1cc fix: remove non-existent Podcast type check
Use mediaType enum check instead of type check since Podcast
is not a distinct class in the model hierarchy.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:29:25 +00:00
Your NameandClaude Opus 4.5 8a38516715 feat: overhaul search scoring with fuzzy matching and stopword removal
Implements a modular search scoring system that fixes common search issues:

- Stopword removal: "the Ramones" now finds "Ramones"
- Reverse matching: Query containing result name gets scored
- Fuzzy matching: Jaro-Winkler algorithm handles typos ("beetles" -> "Beatles")
- N-gram matching: Bigrams enable partial string matches

New scoring tiers (highest to lowest):
- Exact match (100)
- Exact after stopword removal (95)
- Starts with (80-85)
- Word boundary (65-70)
- Reverse contains (55-60)
- Contains (45-55)
- Fuzzy high confidence (40-45)
- Fuzzy medium confidence (35)
- N-gram partial (25-35)
- Baseline (20)

New files:
- lib/utils/search/scoring_config.dart - Configurable weights
- lib/utils/search/text_normalizer.dart - Stopword removal
- lib/utils/search/fuzzy_matcher.dart - Jaro-Winkler implementation
- lib/utils/search/ngram_matcher.dart - Bigram matching
- lib/utils/search/search_scoring.dart - Main scorer class

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:18:54 +00:00
Your NameandClaude Opus 4.5 aaeb2109f6 chore: remove debug logging for radio metadata
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:55:58 +00:00
Your NameandClaude Opus 4.5 f65e76e858 fix: prevent radio metadata downgrade from bad events
- Remove faulty existingTrack.name.contains(' - ') check that
  incorrectly marked good metadata as not-proper
- Add protection for malformed titles starting with '- '
- Add debug logging for radio metadata quality comparison

The old check was meant to detect unparsed "Artist - Title" format
but it also blocked legitimate titles like "Jazzy French - La musique
revient vite..." from being recognized as proper metadata.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:31:49 +00:00
Your NameandClaude Opus 4.5 252395d164 Fix _currentTrack not updating for radio streams
The cache was being updated correctly with stream metadata, but
_currentTrack (which the UI displays) was only updated for podcasts.

Now also updates _currentTrack for radio when stream metadata arrives
(detected by artist changing from Unknown to actual value).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:10:24 +00:00
Your NameandClaude Opus 4.5 f36d108878 Fix radio stream metadata not displaying
For radio streams, MA sends now-playing info in `stream_title` field
(e.g., "Jazzy French - La musique revient vite...") while `title` field
contains the station name (e.g., "Jazz Radio Jazzy French").

Previously, stream_title was only used if title was "Unknown Track".
Now we always prefer stream_title for radio streams, parsing the
"Artist - Title" format properly.

Bump version to 2.8.4-beta+41

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:54:14 +00:00
Your NameandClaude Opus 4.5 8e548daf0c Fix black screen bug when Material You theme enabled (#37)
Override Material You ColorScheme backgrounds with app's preferred colors.
Material You generates unpredictable background values that could cause
black screen or inconsistent navigation bar colors.

- Force dark background to 0xFF1a1a1a
- Force dark surface to 0xFF2a2a2a
- Force light background to 0xFFFAFAFA

Bump version to 2.8.3-beta+40

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:27:46 +00:00
111 changed files with 27684 additions and 5224 deletions
+16 -1
View File
@@ -35,6 +35,16 @@ jobs:
channel: 'stable'
cache: true
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Get dependencies
run: flutter pub get
@@ -65,7 +75,12 @@ jobs:
EOF
- name: Build APK
run: flutter build apk --release
run: |
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
mkdir -p ~/.gradle
cp android/init.gradle ~/.gradle/init.gradle
flutter build apk --release
- name: Upload APK artifact
uses: actions/upload-artifact@v4
+182
View File
@@ -0,0 +1,182 @@
# Player System Animation & Polish Audit
## Context
This is a Music Assistant client app (Ensemble) built with Flutter. The player system has undergone extensive development and now needs a comprehensive polish audit. The goal is buttery smooth 60fps animations and a premium user experience.
## Architecture Overview
The player system consists of three interconnected components:
1. **Mini Player** (collapsed state) - Floating card at bottom, shows current track
2. **Expanded Player** - Full-screen player with album art, controls, progress
3. **Queue Panel** - Slides in from right of expanded player, shows playback queue
Additionally:
- **Player Selector** - Horizontal swipe between multiple players (collapsed mode)
- **Device Reveal** - Swipe down from mini player to show available players
## Key Files
```
lib/widgets/
├── expandable_player.dart # Main player widget (1800+ lines)
├── global_player_overlay.dart # Overlay wrapper, static accessors
├── player/
│ ├── queue_panel.dart # Queue list with great_list_view
│ ├── chapters_panel.dart # Audiobook chapters
│ └── mini_player_content.dart
```
## CRITICAL CONSTRAINTS
1. **DO NOT MODIFY the queue list implementation** - It uses `great_list_view` package with `AutomaticAnimatedListView`. This was chosen specifically to avoid Flutter's grey screen overlay bug (#103804). The current implementation works correctly.
2. **Preserve all existing functionality** - Focus on polish, not rewrites
3. **Test changes incrementally** - This system is complex with many interdependencies
## Audit Areas
### 1. Animation Performance Audit
Use sub-agents to analyze each animation system:
**Agent 1: Expand/Collapse Animation**
- Analyze `_controller` and `_expandAnimation`
- Check for jank during morphing transition
- Verify lerp functions are efficient
- Look for unnecessary rebuilds during animation
- Check `RepaintBoundary` placement
**Agent 2: Queue Panel Animation**
- Analyze `_queuePanelController` and `_queuePanelAnimation`
- Verify slide-in/out is smooth
- Check interaction with expanded player state
- Verify the Listener-based swipe detection doesn't cause issues
**Agent 3: Player Selector Animation**
- Analyze horizontal swipe between players
- Check `_slideOffset` and peek player animations
- Verify finger-following feels natural
- Look for edge cases with rapid swipes
**Agent 4: Micro-interactions**
- Play/pause button animations
- Progress bar interactions
- Volume swipe overlay
- Favorite button feedback
- Skip button responsiveness
### 2. State Management Audit
**Check for:**
- Unnecessary `setState` calls during animations
- ValueNotifier usage efficiency
- Animation listener cleanup
- Memory leaks from subscriptions
- State synchronization between components
### 3. Visual Polish Audit
**Check for:**
- Color transitions during expand/collapse
- Text fade/scale animations
- Image loading placeholders
- Shadow/elevation consistency
- Border radius morphing smoothness
- Safe area handling
### 4. Gesture Conflict Resolution
**Verify:**
- Vertical drag (expand/collapse) vs horizontal drag (player switch)
- Queue panel swipe-right-to-close vs Dismissible swipe-left-to-delete
- Edge dead zones for Android back gesture
- Tap vs drag disambiguation
### 5. Edge Cases
**Test scenarios:**
- Rapid expand/collapse
- Swipe during animation
- Back button during animations
- Track change during queue scroll
- Network image loading failures
- Very long track/artist names
## Execution Strategy
```
ultrathink: true
```
### Phase 1: Discovery (Read-Only)
Launch parallel sub-agents to analyze each file:
- Read and understand the animation architecture
- Map all animation controllers and their relationships
- Identify potential performance bottlenecks
- Document current RepaintBoundary usage
### Phase 2: Analysis Report
Compile findings into categories:
- **Critical**: Causes visible jank or bugs
- **Important**: Noticeable but not breaking
- **Nice-to-have**: Minor polish improvements
### Phase 3: Implementation Plan
For each finding:
- Specific file and line numbers
- Proposed change
- Risk assessment
- Testing approach
### Phase 4: Incremental Fixes
- One fix at a time
- Build and test after each change
- Verify no regressions
## Output Format
Provide a structured audit report:
```markdown
## Animation Audit Report
### Executive Summary
[2-3 sentences on overall state]
### Critical Issues
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
### Important Issues
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
### Polish Opportunities
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
### Architecture Observations
[Notes on overall structure, patterns noticed]
### Recommended Priority Order
1. [First fix]
2. [Second fix]
...
```
## Success Criteria
After this audit and fixes:
- All animations run at consistent 60fps
- No jank during any transition
- Gestures feel responsive and natural
- State transitions are seamless
- No visual glitches or flickers
- Memory usage is stable during interactions
## Notes
- The app uses adaptive theming (colors extracted from album art)
- There's an `AnimationDebugger` utility for profiling
- Position tracking uses a shared `PositionTracker` singleton
- The player supports both music and audiobooks (different UI modes)
+216 -95
View File
@@ -1,136 +1,257 @@
# Ensemble Flutter App - Code Audit Report
# Ensemble Codebase Audit Report
**Date:** January 4, 2026
**Branch:** `audit/code-quality-review`
**Files Audited:** 93 Dart files
**Date**: 2026-01-14
**Scope**: Full codebase audit (104 Dart files, 18 screens)
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
---
## Executive Summary
The codebase is generally well-maintained with good existing performance optimizations (RepaintBoundary, ListView.builder, cacheExtent settings). Key improvement areas identified:
The Ensemble codebase is a **mature, feature-rich Flutter application** with many good patterns already in place. However, the audit identified **significant architectural debt** that should be addressed:
| Category | Issues Found | Priority |
|----------|-------------|----------|
| Deprecated APIs | 20 instances | HIGH |
| Missing const constructors | 100+ instances | MEDIUM |
| Image cache parameters | 7 locations | MEDIUM |
| Code duplication | 8 view mode functions | LOW |
| Color constants | 3 files with duplicates | LOW |
| Severity | Count | Key Themes |
|----------|-------|------------|
| **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
| **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
| **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
| **Low** | 15+ | Minor polish items |
**Top 3 Priorities:**
1. **Split `MusicAssistantProvider` (5,443 lines)** - God object handling everything
2. **Split `new_library_screen.dart` (4,209 lines)** - 10 scroll controllers, 15+ view modes
3. **Fix startup performance** - Sequential blocking ops could save 1-8 seconds
---
## 1. CRITICAL: Deprecated API Usage
## Critical Issues
### `colorScheme.background` (20 instances)
Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`.
### 1. God Object: MusicAssistantProvider (5,443 lines)
**File**: `lib/providers/music_assistant_provider.dart`
- 54 `notifyListeners()` calls
- 30+ state variables
- 100+ public methods
- Handles: connection, auth, players, library, search, caching, local playback, Sendspin
- **Fix**: Split into ConnectionManager, PlayerStateManager, LibraryStateManager, SearchStateManager
**Files affected:**
- lib/main.dart:335, 336
- lib/screens/library_artists_screen.dart:21
- lib/screens/new_home_screen.dart:121
- lib/screens/artist_details_screen.dart:708, 720
- lib/screens/library_playlists_screen.dart:53
- lib/screens/library_tracks_screen.dart:19
- lib/screens/login_screen.dart:489
- lib/screens/new_library_screen.dart:933, 957, 1020, 1021
- lib/screens/album_details_screen.dart:807, 819
- lib/screens/library_albums_screen.dart:19
- lib/screens/search_screen.dart:427, 738, 739
- lib/screens/settings_screen.dart:245
### 2. God Object: NewLibraryScreen (4,209 lines)
**File**: `lib/screens/new_library_screen.dart`
- 10 scroll controllers
- 15+ view mode states
- 15+ sort order states
- Handles: Music, Books, Podcasts, Radio tabs
- **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
### 3. Sequential Blocking Startup (500-1500ms wasted)
**File**: `lib/main.dart:33-66`
```dart
await DatabaseService.instance.initialize();
await ProfileService.instance.migrateFromOwnerName();
await SettingsService.migrateToSecureStorage();
await SyncService.instance.loadFromCache();
audioHandler = await AudioService.init(...);
```
- **Fix**: Parallelize with `Future.wait()`, defer AudioService to post-first-frame
### 4. Design Tokens Exist But Unused
**File**: `lib/theme/design_tokens.dart`
- `Spacing.*` used in only ~6 of 34+ widget files
- `Radii.*` used in only ~5 files
- `IconSizes.*` used in only ~4 files
- **Fix**: Systematic replacement of hardcoded values
### 5. Touch Targets Too Small (32x32)
**File**: `lib/screens/new_library_screen.dart:1805-1867`
- Sort, favorites, view mode buttons are 32x32
- Material Design minimum: 48x48
- **Fix**: Increase to 48x48 or add padding for hit area
### 6. No Batch Database Inserts
**File**: `lib/services/sync_service.dart:212-273`
- 1000 albums = 1000 separate DB transactions
- **Fix**: Wrap in single transaction or use Drift batch insert
---
## 2. HIGH: Missing const Constructors
## High Severity Issues
### EdgeInsets.zero (64 instances)
High-impact optimization - used extensively in button padding.
### Performance
| Issue | File | Impact |
|-------|------|--------|
| `context.watch()` in nested tabs | new_library_screen.dart:2960 | All tabs rebuild on any provider change |
| Consumer wrapping 800-line player | expandable_player.dart:1234 | Full rebuild on every state change |
| Inline sorting during build | new_library_screen.dart:3647 | 5-20ms for 1000+ albums |
| Multiple setState for author images | new_library_screen.dart:1017 | 20+ rebuilds as images load |
| SecureStorage migration every startup | secure_storage_service.dart:103 | 100-300ms wasted |
**Key files:**
- lib/screens/album_details_screen.dart (12 instances)
- lib/screens/artist_details_screen.dart (8 instances)
- lib/screens/audiobook_detail_screen.dart (3 instances)
- lib/screens/audiobook_series_screen.dart (2 instances)
- lib/screens/audiobook_author_screen.dart (2 instances)
### Architecture
| Issue | File | Impact |
|-------|------|--------|
| State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
| 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
| 60+ duplicate getter/setter pairs | settings_service.dart:500-1000 | Code bloat |
| Global singleton NavigationProvider | navigation_provider.dart:40 | Untestable |
| CacheService duplicated in providers | Multiple | Memory waste, inconsistency |
### BorderRadius.circular (40+ instances)
Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`.
### UI/UX
| Issue | File | Impact |
|-------|------|--------|
| Hardcoded bottom spacing (140, 164, 100) | Multiple detail screens | Content obscured by player |
| Inconsistent button sizes (44 vs 50) | Multiple screens | Visual disharmony |
| No debounce on card taps | album_card.dart:118 | Double navigation possible |
| Missing PopScope on AudiobookSeriesScreen | audiobook_series_screen.dart:276 | Adaptive colors not cleared |
### Error Handling
| Issue | File | Impact |
|-------|------|--------|
| No Flutter error boundaries | main.dart | Red error screen on build failure |
| API returns empty list on error | music_assistant_api.dart | Can't distinguish error from empty |
---
## 3. MEDIUM: Scroll Performance - Missing Image Cache Parameters
## Medium Severity Issues
### CachedNetworkImage without memCacheWidth/Height
### Performance
- PaletteGenerator on main isolate (10-30ms blocking)
- Multiple `SharedPreferences.getInstance()` calls (132 occurrences)
- AppStartup polling loop with fixed 250ms delay
- SystemThemeHelper FutureBuilder recreated on every build
**lib/screens/new_library_screen.dart:**
- Line 1395-1402: Author image (48x48) - missing cache params
- Line 1480-1486: Author card image - missing cache params
- Line 1572-1580: Audiobook cover (56x56) - missing cache params
- Line 1740-1745: Audiobook grid cover - missing cache params
- Line 1959-1969: Playlist cover (56x56) - missing cache params
- Line 2143-2152: Playlist grid covers - missing cache params
- Line 2973-2981: Album cover (56x56) - missing cache params
### UI Consistency
- Border radius varies: 8, 12, 20, 22, 25 for same element types
- `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
- Hardcoded colors for status (Colors.red, Colors.green)
- Inconsistent icon sizes across screens
**Note:** DecorationImage with CachedNetworkImageProvider cannot have cache size specified (structural Flutter limitation).
### State Management
- Over-notification (multiple notifyListeners in single methods)
- Wide Consumer scope (watch instead of select)
- Missing dispose() in some providers
- Inconsistent async state handling (loading/error)
### Navigation
- `isQueuePanelOpen` vs `isQueuePanelTargetOpen` inconsistency
- `popToHome()` doesn't clear adaptive colors
- Infinite navigation loop possible (Album → Artist → Album)
- Orphaned Hero animations in podcast_card, radio_station_card
### Data/Caching
- Unbounded `_podcastCoverCache`
- SyncService listener not removed on error
- Redundant API calls between providers
- Fire-and-forget persistence race condition
### Error Handling
- No zone error handler for async exceptions
- `retryCritical()` retries all errors including non-recoverable
- No user notification of offline queue status
- DisconnectedState not used consistently
---
## 4. LOW: Code Duplication
## Quick Wins (High Impact, Low Effort)
### View Mode Cycling Functions (8 nearly identical functions)
**File:** lib/screens/new_library_screen.dart
Functions that follow identical switch/case pattern:
- `_cycleArtistsViewMode()` (line 184)
- `_cycleAlbumsViewMode()` (line 200)
- `_cyclePlaylistsViewMode()` (line 216)
- `_cycleAuthorsViewMode()` (line 232)
- `_cycleAudiobooksViewMode()` (line 248)
- `_cycleSeriesViewMode()` (line 286)
- `_cycleRadioViewMode()` (line 303)
- `_cyclePodcastsViewMode()` (line 319)
**Recommendation:** Create generic utility function.
### Duplicated Color Constants (3 files)
Series fallback colors defined identically in:
- lib/widgets/series_row.dart:246-253
- lib/screens/audiobook_series_screen.dart:466-473
- lib/screens/new_library_screen.dart:2112-2119
**Recommendation:** Extract to lib/constants/colors.dart
| # | Task | File(s) | Effort |
|---|------|---------|--------|
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
| 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
| 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
| 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
| 5 | Add Flutter error boundary | main.dart | 30 min |
| 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
| 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
---
## 5. POSITIVE FINDINGS (Already Optimized)
## Refactoring Roadmap
- **RepaintBoundary**: Properly implemented in all list item widgets
- **ListView.builder**: Used consistently with cacheExtent: 1000
- **ScrollController disposal**: All 8+ controllers properly disposed
- **Animation patterns**: Proper listener cleanup in expandable_player.dart
- **Debounced operations**: Color extraction debounced to 300ms
### Phase 1: Quick Wins (1-2 days)
- Fix touch targets
- Fix bottom spacing
- Add navigation debounce
- Add error boundaries
- Cache SharedPreferences singleton
### Phase 2: Performance (3-5 days)
- Parallelize startup operations
- Replace `context.watch()` with `context.select()`
- Batch database inserts
- Add SecureStorage migration skip flag
- Fix over-notification in provider
### Phase 3: Design System (1 week)
- Systematic design token adoption
- Standardize button sizes (44 vs 50)
- Standardize border radius values
- Create StatusColors semantic tokens
- Replace all magic numbers
### Phase 4: Architecture (2-3 weeks)
- ~~Split MusicAssistantProvider into focused managers~~ **DEFERRED** - MusicAssistantProvider has evolved with Sendspin, Cast-Sendspin ID mapping, position tracker integration. Splitting would require ~1600 lines of sync work. Code is working and tested.
- ~~Split new_library_screen into tab widgets~~ **DEFERRED** - Tabs have tight state coupling (15+ view modes, 15+ sort orders, 10 scroll controllers). Extraction would require InheritedWidget/callbacks for all shared state. Risk outweighs benefit.
- ✅ Extract generic helpers for SettingsService - Reduced from 1,031 to 874 lines (~15%)
- ✅ Remove/integrate unused providers - Deleted PlayerProvider (964 lines), LibraryProvider, ConnectionProvider (651 lines total)
- ~~Split music_assistant_api.dart by domain~~ **DEFERRED** - All methods share WebSocket connection and auth state. Clean extraction would require connection manager abstraction.
---
## Recommended Action Plan
## Positive Findings
### Phase 1: Quick Wins (Low Risk)
1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations)
2. Add `const` to EdgeInsets.zero instances
The codebase already demonstrates many good practices:
### Phase 2: Image Optimization
1. Add memCacheWidth/memCacheHeight to CachedNetworkImage instances in new_library_screen.dart
### Phase 3: Code Cleanup (Optional)
1. Extract view mode cycling to utility function
2. Centralize color constants
- **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
- **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
- **Theme system**: Material You support, adaptive colors from album art
- **Offline support**: Cache-first loading, graceful degradation
- **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
---
## Files by Complexity
## Files by Priority
| File | Lines | Status |
|------|-------|--------|
| lib/screens/search_screen.dart | 3,489 | Good structure, minor fixes needed |
| lib/screens/new_library_screen.dart | 3,351 | Good performance, some duplication |
| lib/widgets/expandable_player.dart | 2,577 | Well-optimized animations |
| Priority | File | Lines | Key Issues |
|----------|------|-------|------------|
| 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
| 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
| 3 | main.dart | 549 | Startup blocking, error boundaries |
| 4 | music_assistant_api.dart | 3,434 | Silent failures, needs splitting |
| 5 | settings_service.dart | 1,019 | Code duplication, no caching |
| 6 | expandable_player.dart | ~800 | Wide Consumer scope |
| 7 | global_player_overlay.dart | ~500 | BottomSpacing source, Consumer scope |
| 8 | All detail screens | Various | Hardcoded bottom spacing |
| 9 | All card widgets | Various | Navigation debounce, design tokens |
---
## Audit Methodology
This audit was conducted using 9 parallel sub-agents analyzing:
**UI Continuity (3 agents)**
1. Navigation & Flow - back button, Hero animations, tab preservation
2. Visual Consistency - design tokens, spacing, typography
3. Component Placement - touch targets, bottom spacing, modals
**Performance (3 agents)**
4. Rendering Performance - ListView.builder, const, rebuilds
5. Data & Caching - streams, invalidation, memory bounds
6. Startup & Load Time - blocking operations, initialization order
**Logic/Best Practices (3 agents)**
7. State Management - Provider patterns, notification frequency
8. Error Handling - try-catch, boundaries, resilience
9. Code Quality - file size, duplication, SOLID principles
---
## Conclusion
The Ensemble app is well-architected at the macro level but has accumulated technical debt in its largest files. The most impactful improvements would be:
1. **Splitting the two 4,000+ line files** into focused components
2. **Adopting design tokens systematically** for visual consistency
3. **Optimizing startup sequence** for faster app launch
4. **Using granular state management** (select vs watch) for better performance
The quick wins section provides immediate value with minimal risk, while the roadmap offers a path to sustainable improvement.
+66 -25
View File
@@ -20,44 +20,63 @@ This application was built with AI-assisted development using **Claude Code** an
## Features
### Local Playback
- **Stream to Your Phone** - Play music from your Music Assistant library directly on your mobile device
- **Stream to Your Phone** - Play music from your Music Assistant library directly on your mobile device via Sendspin protocol
- **Background Playback** - Music continues playing when the app is minimized
- **Media Notifications** - Control playback from your notification shade with album art display
- **Instant Response** - Pause/resume in ~300ms
### Remote Control
- **Multi-Player Support** - Control any speaker or device connected to Music Assistant
- **Device Selector** - Quickly switch between your phone and other players
- **Device Selector** - Swipe down on mini player to reveal all your devices
- **Multi-Room Grouping** - Long-press any player to sync it with the current player
- **Full Playback Controls** - Play, pause, skip, seek, and adjust volume
- **Queue Management** - View and manage the playback queue
- **Volume Precision Mode** - Hold the volume slider for fine-grained control with haptic feedback
- **Power Control** - Turn players on/off directly from the mini player
### Queue Management
- **View & Manage Queue** - See upcoming tracks in the playback queue
- **Drag to Reorder** - Instant drag handles for reordering tracks
- **Swipe to Delete** - Remove tracks with a simple swipe gesture
### Home Screen
- **Customizable Rows** - Toggle Recently Played, Discover Artists, and Discover Albums
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, and Tracks
- **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, Tracks, Playlists, and Radio Stations
- **Adaptive Layout** - Rows scale properly for different screen sizes and aspect ratios
- **Pull to Refresh** - Refresh content with a simple pull gesture
### Library Browsing
- **Browse Your Collection** - Artists, albums, playlists, and tracks from all your music sources
### Library
- **Music** - Browse artists, albums, playlists, and tracks from all your music sources
- **Radio Stations** - Browse and play radio stations with list or grid view
- **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates
- **Audiobooks** - Browse by title, series, or author with progress tracking
- **Favorites Filter** - Toggle to show only your favorite items
- **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
- **Letter Scrollbar** - Fast navigation through long lists
### Search
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
- **Fuzzy Matching** - Typo-tolerant search (e.g., "beetles" finds "Beatles")
- **Smart Scoring** - Results ranked by relevance with colored type indicators
- **Search History** - Quickly access your recent searches
- **Quick Actions** - Long-press any result to add to queue or play next
### Audiobooks
- **Audiobooks Tab** - Browse your audiobook library with grid/list view options
- **Series Support** - View audiobooks organized by series with collage cover art
- **Author Browsing** - Browse audiobooks by author
- **Chapter Navigation** - Jump between chapters with timestamp display
- **Progress Tracking** - Track your listening progress across sessions
- **Continue Listening** - Pick up where you left off
- **Mark as Finished/Unplayed** - Manage your reading progress
- **Series Support** - View audiobooks organized by series with collage cover art
### Podcasts
- **Episode Browser** - View full episode list with artwork and descriptions
- **Skip Controls** - Skip forward/backward during playback
- **High-Resolution Artwork** - Fetched via iTunes for best quality
### Smart Features
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
- **Auto-Reconnect** - Automatically reconnects when connection is lost
- **Offline Browsing** - Browse your cached library even when disconnected
- **Hero Animations** - Smooth transitions between screens
- **Welcome Screen** - Guided onboarding for first-time users
### Theming
- **Material You** - Dynamic theming based on your device's wallpaper
@@ -67,14 +86,16 @@ This application was built with AI-assisted development using **Claude Code** an
## Screenshots
<div align="center">
<img src="assets/screenshots/1.png?v=2" alt="Connection Screen" width="150">
<img src="assets/screenshots/2.png?v=2" alt="Home Screen" width="150">
<img src="assets/screenshots/3.png?v=2" alt="Album Details" width="150">
<img src="assets/screenshots/4.png?v=2" alt="Now Playing" width="150">
<img src="assets/screenshots/5.png?v=2" alt="Queue" width="150">
<img src="assets/screenshots/6.png?v=2" alt="Settings" width="150">
<img src="assets/screenshots/7.png?v=2" alt="Audiobooks" width="150">
<img src="assets/screenshots/8.png?v=2" alt="Audiobook Player" width="150">
<img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150">
<img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150">
<img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150">
<img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150">
<img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150">
<img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150">
<img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" width="150">
<img src="assets/screenshots/8.png?v=3" alt="Screenshot 8" width="150">
<img src="assets/screenshots/9.png?v=3" alt="Screenshot 9" width="150">
<img src="assets/screenshots/10.png?v=3" alt="Screenshot 10" width="150">
</div>
## Download
@@ -84,9 +105,29 @@ Download the latest release from the [Releases page](https://github.com/CollotsS
## Setup
1. Launch the app
2. Enter your Music Assistant server URL (e.g., `music.example.com` or `192.168.1.100`)
2. Enter your Music Assistant server URL
3. Connect to your server
4. Start playing! Music plays on your phone by default, or tap the device icon to choose a different player.
4. Start playing! Music plays on your phone by default, or swipe down on the mini player to choose a different player.
### Finding Your Server URL
**Important:** You need the **Music Assistant** URL, not your Home Assistant URL.
To find the correct URL:
1. Open Music Assistant web UI
2. Go to **Settings** > **About**
3. Look for **Base URL** (e.g., `http://192.168.1.100:8095`)
### Home Assistant Add-on Users
If you run Music Assistant as a Home Assistant add-on:
- Use the IP address of your Home Assistant server
- Enter `8095` in the port field
- Do **not** use your Home Assistant URL or ingress URL
### Remote Access
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
## Authentication
@@ -103,7 +144,7 @@ Ensemble supports multiple authentication methods:
## Requirements
- Music Assistant server (v2.7.0 beta 20 or later)
- Music Assistant server (v2.7.0 beta 20 or later recommended)
- Network connectivity to your Music Assistant server
- Android device (Android 5.0+)
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
+1
View File
@@ -11,6 +11,7 @@
android:label="Ensemble"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:appCategory="audio"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+2 -1
View File
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launch_background_color">#1a1a1a</color>
</resources>
<color name="ic_launcher_background">#1a1a1a</color>
</resources>
+6
View File
@@ -2,6 +2,9 @@ buildscript {
ext.kotlin_version = '1.9.10'
repositories {
google()
// Use Maven Central mirrors as fallback for 403 errors
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
mavenCentral()
}
@@ -14,6 +17,9 @@ buildscript {
allprojects {
repositories {
google()
// Use Maven Central mirrors as fallback for 403 errors
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
mavenCentral()
}
}
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
+37
View File
@@ -0,0 +1,37 @@
// Force all repositories to use mirrors first
allprojects {
repositories {
all { ArtifactRepository repo ->
if (repo instanceof MavenArtifactRepository) {
def url = repo.url.toString()
if (url.contains('repo.maven.apache.org') || url.contains('repo1.maven.org')) {
remove repo
}
}
}
// Add mirrors before Maven Central
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/public' }
}
}
settingsEvaluated { settings ->
settings.pluginManagement {
repositories {
all { ArtifactRepository repo ->
if (repo instanceof MavenArtifactRepository) {
def url = repo.url.toString()
if (url.contains('repo.maven.apache.org') || url.contains('repo1.maven.org')) {
remove repo
}
}
}
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/public' }
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+336
View File
@@ -0,0 +1,336 @@
# Per-Provider Sync Implementation
## Date: January 17, 2026
## Overview
This document details the implementation of per-provider sync with source tracking, enabling instant client-side filtering that properly differentiates between multiple accounts of the same provider type (e.g., two different Spotify accounts).
## Problem Statement
### The Core Issue
When a user has multiple accounts of the same provider type (e.g., two Spotify accounts: "Chris Laycock" and "Bababagoly"), the existing provider filter couldn't differentiate between them.
**Why?** Music Assistant's `provider_mappings` field on media items shows which providers **CAN PLAY** an item, not which provider **ADDED** it. When both Spotify accounts can play the same artist, both appear in `provider_mappings`, making client-side filtering impossible.
### Previous Approach (Failed)
The initial implementation tried to filter client-side using `provider_mappings`:
```dart
bool isItemAllowedByProviderFilter(MediaItem item) {
final mappings = item.providerMappings;
for (final mapping in mappings) {
if (_providerFilter.contains(mapping.providerInstance)) {
return true;
}
}
return false;
}
```
**Result:** Both Spotify accounts showed the same items because `provider_mappings` contained both account IDs for shared content.
### Race Condition Issue
Additionally, when rapidly toggling providers, a race condition occurred:
1. User toggles Provider A off
2. Sync starts for remaining providers
3. User toggles Provider B off before sync completes
4. First sync completes with wrong filter state
5. UI shows incorrect results
## Solution: Per-Provider Sync with Source Tracking
### Concept
Instead of relying on `provider_mappings`, we:
1. **Sync each provider separately** using the MA API's `provider` parameter
2. **Tag each item** with which provider instance(s) returned it
3. **Store source tracking** in the local database
4. **Filter client-side** using the source tracking data (instant)
5. **Background sync** keeps data fresh without blocking UI
### Why This Works
When we call the MA API with `provider: ["spotify--RKnr3jKx"]`, the server returns **only items from that specific account's library**. By syncing each provider separately and remembering which provider returned each item, we can accurately filter.
## Implementation Details
### 1. Database Schema Update
**File:** `lib/database/database.dart`
Added `sourceProviders` column to `LibraryCache` table:
```dart
class LibraryCache extends Table {
// ... existing columns ...
/// Provider instance IDs that provided this item (JSON array)
/// Used for client-side filtering by source provider
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
}
```
**Schema version:** 5 → 6
**Migration:**
```dart
if (from < 6) {
await customStatement(
"ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'"
);
}
```
### 2. Database Service Updates
**File:** `lib/services/database_service.dart`
Added `sourceProvider` parameter to `cacheItem()`:
```dart
Future<void> cacheItem<T>({
required String itemType,
required String itemId,
required Map<String, dynamic> data,
String? sourceProvider, // NEW: tracks which provider this item came from
})
```
Added new method to retrieve items with source tracking:
```dart
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType)
```
Returns tuples of `(itemData, sourceProviderIds)`.
### 3. SyncService Rewrite
**File:** `lib/services/sync_service.dart`
#### New Fields
```dart
// Source provider tracking for client-side filtering
Map<String, List<String>> _albumSourceProviders = {};
Map<String, List<String>> _artistSourceProviders = {};
Map<String, List<String>> _audiobookSourceProviders = {};
Map<String, List<String>> _playlistSourceProviders = {};
```
#### Per-Provider Sync Logic
```dart
Future<void> syncFromApi(MusicAssistantAPI api, {
bool force = false,
List<String>? providerInstanceIds,
}) async {
// If specific providers are requested, sync each separately
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
for (final providerId in providerInstanceIds) {
// Fetch from this specific provider
final results = await Future.wait([
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
api.getArtists(limit: 1000, providerInstanceIds: [providerId]),
// ... etc
]);
// Track source provider for each item
for (final album in albums) {
albumMap[album.itemId] = album;
_albumSourceProviders.putIfAbsent(album.itemId, () => []);
if (!_albumSourceProviders[album.itemId]!.contains(providerId)) {
_albumSourceProviders[album.itemId]!.add(providerId);
}
}
}
}
}
```
#### Client-Side Filtering Methods
```dart
/// Filter albums by source provider (instant, no network)
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
if (enabledProviderIds.isEmpty || _albumSourceProviders.isEmpty) {
return _cachedAlbums;
}
return _cachedAlbums.where((album) {
final sources = _albumSourceProviders[album.itemId];
if (sources == null || sources.isEmpty) return true; // No tracking = show
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
}
// Similar methods for artists, audiobooks, playlists
```
### 4. Library Screen Updates
**File:** `lib/screens/new_library_screen.dart`
#### Hybrid Provider Toggle Handler
```dart
void _handleProviderToggle(String providerId, bool enabled) {
final maProvider = context.read<MusicAssistantProvider>();
// Instant UI update via global provider toggle
maProvider.toggleProviderEnabled(providerId, enabled);
setState(() {}); // Rebuild with client-side filtering
// Background sync after 1.5s debounce
_providerFilterDebounce?.cancel();
_providerFilterDebounce = Timer(const Duration(milliseconds: 1500), () async {
if (mounted) {
await maProvider.forceLibrarySync();
if (mounted) setState(() {});
}
});
}
```
#### Updated Tab Builders
Artists tab now uses source tracking:
```dart
Widget _buildArtistsTab(BuildContext context, S l10n) {
return Selector<MusicAssistantProvider, (List<Artist>, bool, Set<String>)>(
selector: (_, provider) => (
provider.artists,
provider.isLoading,
provider.enabledProviderIds.toSet()
),
builder: (context, data, _) {
final (allArtists, isLoading, enabledProviders) = data;
// Client-side filtering using SyncService source tracking
final syncService = SyncService.instance;
final filteredArtists = syncService.hasSourceTracking && enabledProviders.isNotEmpty
? syncService.getArtistsFilteredByProviders(enabledProviders)
: allArtists;
// ... rest of build
},
);
}
```
Similar updates for albums, playlists, and audiobooks tabs.
### 5. Provider Instance Model
**File:** `lib/models/provider_instance.dart` (new file)
```dart
class ProviderInstance {
final String instanceId; // e.g., "spotify--abc123"
final String domain; // e.g., "spotify"
final String name; // e.g., "Spotify (John's Account)"
final bool available;
/// Known music provider domains
static const Set<String> musicProviderDomains = {
'spotify', 'tidal', 'qobuz', 'deezer', 'ytmusic', 'soundcloud',
'apple_music', 'amazon_music', 'plex', 'subsonic', 'opensubsonic',
'jellyfin', 'emby', 'navidrome', 'audiobookshelf', 'filesystem',
'tunein', 'radiobrowser', 'snapcast', 'fully_kiosk',
};
bool get isMusicProvider => musicProviderDomains.contains(domain);
}
```
## Data Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ User Toggles Provider │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. MusicAssistantProvider.toggleProviderEnabled() │
│ - Updates enabledProviderIds │
│ - Triggers setState() for instant UI update │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. UI Rebuilds Immediately │
│ - Selector detects enabledProviderIds change │
│ - Calls SyncService.getArtistsFilteredByProviders() │
│ - Filters using source tracking (instant, no network) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Background Sync (after 1.5s debounce) │
│ - forceLibrarySync() called │
│ - Syncs each enabled provider separately │
│ - Updates source tracking in database │
│ - UI refreshes with fresh data │
└─────────────────────────────────────────────────────────────────┘
```
## Files Changed
| File | Changes |
|------|---------|
| `lib/database/database.dart` | Added `sourceProviders` column, schema v6, migration |
| `lib/database/database.g.dart` | Auto-generated from schema changes |
| `lib/services/database_service.dart` | Added `sourceProvider` param, `getCachedItemsWithProviders()` |
| `lib/services/sync_service.dart` | Per-provider sync, source tracking maps, filtering methods |
| `lib/screens/new_library_screen.dart` | Hybrid toggle handler, updated tab builders |
| `lib/models/provider_instance.dart` | New model for provider instances |
## Testing Checklist
- [ ] Fresh install: First sync should populate source tracking
- [ ] Toggle single provider: UI updates instantly
- [ ] Toggle multiple providers rapidly: Debounce prevents race conditions
- [ ] Two Spotify accounts: Toggling one only shows that account's items
- [ ] Mixed providers: Toggling Spotify doesn't affect Audiobookshelf items
- [ ] Legacy cache: Items without source tracking show regardless of filter
- [ ] Pull to refresh: Refreshes source tracking data
## Known Limitations
1. **First sync required:** Source tracking only works after at least one sync with providers enabled
2. **Network cost:** Per-provider sync makes N API calls (one per provider) vs. 1 call for all providers
3. **Items in multiple accounts:** If the same item exists in both Spotify accounts, it will show when either is enabled
## Future Improvements
1. **Incremental sync:** Only sync changed items instead of full library
2. **Provider-specific cache age:** Different sync intervals per provider
3. **Offline source tracking:** Persist source tracking independently of full cache
## Branch
All changes are on the `feature/per-provider-sync` branch.
```bash
git checkout feature/per-provider-sync
```
## Commit
```
feat: Add per-provider sync with source tracking for instant filtering
- Add sourceProviders column to database schema (v6) for tracking which
provider instance each item came from
- Update SyncService to sync each enabled provider separately and tag
items with their source provider
- Add client-side filtering methods that use source tracking to properly
differentiate between multiple accounts of the same provider type
- Update library UI to use instant client-side filtering with background
sync for data freshness
```
+217
View File
@@ -0,0 +1,217 @@
# Provider Filter Implementation Notes
## Overview
This document captures the context and learnings from an attempted implementation of provider filtering in the Ensemble app. The goal was to allow users to filter their library to show only items from a specific music provider (e.g., only one Spotify account when multiple are configured).
## The Problem
Users with multiple music provider accounts (e.g., two Spotify accounts: one personal, one family) want to filter their library view to show only items from one specific account. The Music Assistant (MA) web UI successfully implements this feature.
## What Works vs What Failed
### Works: Different Provider Types
Filtering between different provider types (e.g., Spotify vs Tidal vs Plex) works correctly. Each provider type has distinct `provider_mappings` entries.
### Failed: Multiple Accounts of Same Provider
Filtering between multiple Spotify accounts (e.g., `spotify--abc123` vs `spotify--xyz789`) does NOT work correctly. The same library items appear regardless of which Spotify account is selected.
## Root Cause Analysis
### The provider_mappings Problem
MA's `provider_mappings` field on media items does NOT indicate "which account ADDED this item to the library." Instead, it indicates "which providers CAN PLAY this item."
Example: An artist "Ari Mason" that exists ONLY in User A's Spotify library will show provider_mappings containing BOTH Spotify accounts:
```json
{
"provider_mappings": [
{"provider_instance": "spotify--userA_id", "item_id": "..."},
{"provider_instance": "spotify--userB_id", "item_id": "..."}
]
}
```
This happens because:
1. MA's internal database stores that the artist exists
2. MA knows both Spotify accounts CAN play this artist (Spotify has the content)
3. MA doesn't track WHICH account originally added the item to the library
### MA Web UI Behavior
The MA web UI's provider filter works differently - it appears to use server-side filtering via the `provider` parameter on library_items API calls. When you select a single provider in MA settings, the API returns only items from that provider's library.
## API Investigation
### Server-Side Filtering Parameter
The MA server's `library_items` method accepts a `provider` parameter:
- Parameter name: `provider` (string or list of strings)
- Endpoint: `music/{type}/library_items` (e.g., `music/artists/library_items`)
- Expected behavior: Filter to only return items from the specified provider's library
### What We Tried
1. **Client-side filtering based on provider_mappings**
- Result: Failed - all Spotify accounts appear in mappings regardless of which added the item
2. **Server-side filtering with `provider` parameter**
- Result: Partially implemented but behavior was inconsistent
- The API accepts the parameter but may not filter correctly for same-provider-type accounts
3. **Using `provider_instance_id_or_domain` parameter**
- Result: This parameter is for getting specific items by ID, not for filtering library_items
## Code Locations
### Files Modified (in stash)
The implementation attempt is preserved in git stash:
```bash
git stash list # Look for "provider-filter-and-dropdown-work"
git stash show -p stash@{0} # View the changes
```
Key files that were modified:
1. **lib/providers/music_assistant_provider.dart**
- `_disabledProviders`: List of disabled provider instance IDs (local app setting)
- `selectedProviderForFilter`: Returns single enabled provider for server-side filtering
- `toggleProviderEnabled()`: Toggle provider on/off and trigger re-sync
- `loadDisabledProviders()`: Load from local settings
- `filterByProvider()`: Client-side filtering (enhanced version)
- `isItemAllowedByProviderFilter()`: Check if item should be visible
2. **lib/services/music_assistant_api.dart**
- Added `provider` parameter to: `getArtists`, `getAlbums`, `getTracks`, `getRandomArtists`, `getRandomAlbums`
- Added `MusicProvider` model parsing from `/providers` endpoint
- `musicProviders` getter: List of available music providers
3. **lib/services/sync_service.dart**
- Added `provider` parameter to `syncFromApi()` and `forceSync()`
- Pass provider filter to API calls during sync
4. **lib/services/settings_service.dart**
- `getDisabledMusicProviders()`: Get list of disabled provider IDs
- `setDisabledMusicProviders()`: Save disabled providers
- `toggleProviderEnabled()`: Toggle and persist
5. **lib/screens/settings_screen.dart**
- UI for toggling individual providers on/off
- Display provider name and instance ID
6. **lib/screens/new_library_screen.dart**
- Integration with provider filtering
7. **lib/models/music_provider.dart** (new file)
- Model for MA provider instances
- Fields: `instanceId`, `domain`, `name`, `available`
## Race Condition Issue
A race condition was identified where:
1. Initial app sync starts WITHOUT provider filter
2. User toggles provider filter
3. `clearCache()` is called
4. New sync with provider filter tries to start
5. "Sync already in progress" - new sync is skipped
6. Result: Old unfiltered data remains
Fix attempted: Reset `_isSyncing = false` in `clearCache()` to allow fresh sync.
## Potential Solutions to Explore
### 1. Investigate MA Server-Side Filtering
The MA server should support filtering by provider. Need to verify:
- Does the `provider` parameter work for filtering between same-type providers?
- Is there a different parameter or API endpoint?
- Check MA source code: https://github.com/music-assistant/server
### 2. Library Sync Per Provider
Instead of syncing all items and filtering, sync each provider's library separately:
```dart
for (final provider in enabledProviders) {
final items = await api.getAlbums(provider: provider.instanceId);
// Store with provider association
}
```
### 3. Track Item Origin
Store which provider originally added each item:
- Modify database schema to track `source_provider`
- When syncing, record which provider the item came from
- Filter based on source, not playability
### 4. Use MA's Internal Library State
MA might have internal state about which items are in which provider's library. Investigate:
- `/music/library/items` endpoint
- Provider-specific library endpoints
- MA's database schema for library associations
### 5. Check MA User Settings
The MA web UI's provider filter might be stored in user settings:
```dart
final userInfo = await api.getCurrentUserInfo();
// Check for provider_filter or similar field
```
## Testing Notes
### Log Messages to Watch
```
🔌 Selected provider for filter: spotify--xxxxx
🔍 API getArtists args: {limit: 1000, provider: spotify--xxxxx}
🔍 API getArtists returned XXX items
🔌 filterByProvider: XXX → YYY (disabled=[...])
```
### Test Scenario
1. Configure two Spotify accounts in MA
2. Add different artists to each account's library
3. In Ensemble, select only one Spotify account
4. Verify library shows ONLY that account's items
5. Switch to other account, verify different items
### Expected vs Actual
- **Expected**: Artist "Ari Mason" (only in Account A) should NOT appear when Account B is selected
- **Actual**: Artist appears regardless of which account is selected
## MA API Reference
### Get Providers
```
Command: providers
Returns: List of provider instances with type, domain, instanceId, name, available
```
### Library Items with Filter
```
Command: music/artists/library_items
Args: {
limit: int,
offset: int,
provider: string | list<string>, // Filter to specific provider(s)
album_artists_only: bool,
order_by: string
}
```
### Relevant MA Source Files
- `music_assistant/server/controllers/music.py` - Library item retrieval
- `music_assistant/server/models/provider.py` - Provider model
- `music_assistant/server/providers/` - Individual provider implementations
## Conclusion
The core challenge is that MA's `provider_mappings` indicates playability, not ownership. Server-side filtering via the `provider` parameter should work but needs further investigation to understand why it doesn't differentiate between multiple accounts of the same provider type.
The implementation is preserved in the git stash for future reference. The sorting/dropdown improvements were extracted and committed separately.
@@ -0,0 +1,271 @@
# Audiobook Provider Filtering Fix
## Date: January 17, 2026 (Continuation)
## Overview
This session focused on fixing provider filtering issues discovered after the initial provider filtering improvements:
1. Audiobooks were not responding to provider filter changes (browse API ignoring `providerInstanceIds`)
2. UI not updating instantly when toggling providers (missing `notifyListeners()` call)
3. Audiobook tabs not rebuilding on provider changes (using `context.read` instead of `Selector`)
---
## Problem Statement
After implementing capability-based provider filtering for the music library, two issues remained:
### Issue 1: Audiobooks Not Filtering During Sync
The bug report showed:
```
📊 Source tracking: 252 albums, 87 artists have provider info
📚 Total audiobooks from browse: 186
```
- Albums and artists had provider source tracking
- Audiobooks had NO provider source tracking (not listed)
- All 186 audiobooks were returned regardless of provider selection
### Issue 2: UI Not Updating Instantly
When toggling providers in the library menu:
- Music tabs weren't updating immediately
- Audiobooks required manual pull-to-refresh
- Changes only appeared after ~600ms (debounce timer)
---
## Root Cause Analysis
### Cause 1: Browse API Ignoring Provider Filter
In `lib/services/music_assistant_api.dart`, when ABS library filtering is enabled, the `getAudiobooks()` function uses the browse API but was **ignoring** the `providerInstanceIds` parameter:
```dart
// BEFORE: providerInstanceIds was ignored when using browse API
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
}
```
### Cause 2: `notifyListeners()` Called Too Late
In `lib/providers/music_assistant_provider.dart`, the `toggleProviderEnabled()` function only called `notifyListeners()` **inside** the 600ms debounce timer:
```dart
// BEFORE: notifyListeners only called after 600ms debounce
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
forceLibrarySync();
notifyListeners(); // Too late! UI should update immediately
});
```
### Cause 3: Audiobook Tabs Not Listening for Changes
The audiobook tabs used `context.read<MusicAssistantProvider>()` which doesn't trigger rebuilds when the provider changes:
```dart
// BEFORE: context.read doesn't listen for changes
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
final maProvider = context.read<MusicAssistantProvider>();
final enabledProviders = maProvider.enabledProviderIds.toSet();
// ... widget doesn't rebuild when enabledProviders changes
}
```
---
## Solutions
### Fix 1: Browse API Provider Filtering
**File:** `lib/services/music_assistant_api.dart` (lines 760-785)
Added logic to filter library paths by provider ID before browsing:
```dart
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
_logger.log('📚 getAudiobooks: enabledLibraries=$enabledLibraries, providerInstanceIds=$providerInstanceIds');
// If specific providers are requested, filter libraries to only matching providers
// Library paths start with provider ID (e.g., "audiobookshelf--abc123://lb...")
var librariesToBrowse = enabledLibraries;
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
librariesToBrowse = enabledLibraries.where((libPath) {
// Extract provider ID from library path (everything before "://")
final providerEnd = libPath.indexOf('://');
if (providerEnd == -1) return false;
final providerId = libPath.substring(0, providerEnd);
return providerInstanceIds.contains(providerId);
}).toList();
// If no libraries match the requested providers, return empty
if (librariesToBrowse.isEmpty) {
return [];
}
}
return await _getAudiobooksFromBrowse(librariesToBrowse, favoriteOnly: favoriteOnly);
}
```
### Fix 2: Immediate `notifyListeners()` Call
**File:** `lib/providers/music_assistant_provider.dart` (line 1149)
Moved `notifyListeners()` to fire immediately after `_enabledProviderIds` changes:
```dart
// Reload the enabled providers
final savedEnabled = await SettingsService.getEnabledMusicProviders();
_enabledProviderIds = savedEnabled ?? [];
// Notify listeners IMMEDIATELY so UI rebuilds with client-side filtering
// This enables instant UI updates using cached data with source tracking
notifyListeners();
// Cancel any pending debounce timer
_providerFilterDebounceTimer?.cancel();
// Start debounced sync - waits 600ms for user to finish toggling
// This refreshes the cache with new data from enabled providers
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
_logger.log('🔄 Debounce complete, starting library sync...');
forceLibrarySync();
});
```
### Fix 3: Audiobook Tabs Use Selector
**File:** `lib/screens/new_library_screen.dart`
Wrapped `_buildBooksAuthorsTab` and `_buildAllBooksTab` in `Selector` to rebuild when `enabledProviderIds` changes:
```dart
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
// Use Selector to rebuild when enabledProviderIds changes
return Selector<MusicAssistantProvider, Set<String>>(
selector: (_, provider) => provider.enabledProviderIds.toSet(),
builder: (context, enabledProviders, _) {
final syncService = SyncService.instance;
// Client-side filtering using SyncService source tracking for instant updates
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
: List<Audiobook>.from(_audiobooks);
// ... rest of widget
},
);
}
```
### Fix 4: Enhanced Sync Logging
**File:** `lib/services/sync_service.dart`
Added audiobook counts to logging:
```dart
// Per-provider log (line 237)
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
// Source tracking summary (line 323)
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
```
---
## Data Flow After Fixes
```
┌─────────────────────────────────────────────────────────────────┐
│ User Toggles Provider │
│ │
│ 1. toggleProviderEnabled() called │
│ 2. _enabledProviderIds updated │
│ 3. notifyListeners() called IMMEDIATELY │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Selectors Detect Change │
│ │
│ Music tabs: Selector<..., (List, bool, Set<String>)> │
│ Audiobook tabs: Selector<..., Set<String>> │
│ │
│ → All tabs rebuild immediately │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Client-Side Filtering (Instant) │
│ │
│ syncService.getAlbumsFilteredByProviders(enabledProviders) │
│ syncService.getArtistsFilteredByProviders(enabledProviders) │
│ syncService.getAudiobooksFilteredByProviders(enabledProviders)│
│ │
│ → Uses EXISTING cache with source tracking │
│ → Instant UI update, no network required │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Background Sync (After 600ms Debounce) │
│ │
│ - Refreshes cache with new data from enabled providers │
│ - Updates source tracking │
│ - Only needed when ENABLING providers (to fetch new data) │
└─────────────────────────────────────────────────────────────────┘
```
---
## Files Changed
| File | Changes |
|------|---------|
| `lib/services/music_assistant_api.dart` | Added provider filtering to browse API path in `getAudiobooks()`, added debug logging |
| `lib/services/sync_service.dart` | Added audiobook counts to per-provider and summary logs |
| `lib/providers/music_assistant_provider.dart` | Moved `notifyListeners()` to fire immediately on provider toggle |
| `lib/screens/new_library_screen.dart` | Wrapped audiobook tabs in `Selector` to rebuild on provider changes |
---
## Expected Behavior After Fixes
1. **Instant UI Updates:** When you toggle a provider, the library immediately updates using client-side filtering
2. **Audiobook Source Tracking:** Audiobooks now have provider source tracking (visible in logs)
3. **Audiobook Tab Rebuilds:** Both audiobook tabs (Authors and All Books) rebuild instantly on provider toggle
4. **Music Tab Consistency:** Music tabs continue to work as before, now with immediate rebuilds
---
## Testing Checklist
- [ ] Toggle a music provider, verify instant UI update (no 600ms delay)
- [ ] Toggle between different Spotify accounts, verify correct filtering
- [ ] Sync with ABS library filtering enabled
- [ ] Verify audiobooks appear in source tracking log
- [ ] Toggle ABS provider off in library menu
- [ ] Verify audiobooks list becomes empty immediately (no pull-to-refresh needed)
- [ ] Toggle ABS provider back on
- [ ] Verify audiobooks reappear after sync completes
- [ ] Test with multiple providers (Spotify audiobooks + ABS)
---
## Related Documentation
- `docs/SESSION_2026_01_17_PROVIDER_FILTERING.md` - Initial provider filtering implementation
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Per-provider sync architecture
---
## Known Limitations
1. **Series Tab:** The Series tab still doesn't filter by provider. Series are fetched as `AudiobookSeries` objects without per-provider tracking.
2. **Mixed Provider Scenario:** If user has both Spotify audiobooks and ABS audiobooks, and ABS library filtering is enabled, Spotify audiobooks may not be fetched because the browse API path is taken. This is a pre-existing architectural limitation.
3. **Enabling Providers:** When ENABLING a previously disabled provider, the UI updates immediately but shows empty until the background sync completes (because the cache doesn't have that provider's data yet). This is expected behavior.
@@ -0,0 +1,217 @@
# Session 2026-01-17: Optimistic Updates & Podcast Provider Filtering
## Overview
This session focused on fixing provider filtering for podcasts, resolving iTunes podcast provider issues, and implementing optimistic updates for library buttons across detail screens.
## Issues Addressed
### 1. Podcast Provider Counts Showing Incorrectly
**Problem**: All Spotify accounts showed the same podcast count instead of only counting podcasts owned by each account.
**Root Cause**: The podcast counting logic wasn't checking the `inLibrary` field on provider mappings.
**Solution**:
- Added `inLibrary` field to `ProviderMapping` model
- Updated `getProvidersWithPodcasts()` to only count mappings where `inLibrary == true`
- Updated podcast filtering in library screen to check `inLibrary` field
**Files Modified**:
- `lib/models/media_item.dart` - Added `inLibrary` field
- `lib/providers/music_assistant_provider.dart` - Updated counting logic
- `lib/screens/new_library_screen.dart` - Updated filtering logic
### 2. iTunes Podcasts Not Appearing in Library
**Problem**: Podcasts added from iTunes search weren't showing in library and iTunes provider filter showed 0 count.
**Investigation**:
- Added `itunes_podcasts` to provider capabilities and music provider domains
- Added synthetic provider instance creation for search-only providers
- Added debug logging to understand podcast loading
**Resolution**: The issue was with Music Assistant's iTunes podcast provider itself - podcasts added via iTunes weren't being properly stored in the MA library. The user removed the iTunes provider from MA and re-added podcasts via Spotify search as a workaround.
**Files Modified**:
- `lib/models/provider_instance.dart` - Added `itunes_podcasts` to capabilities
### 3. Episode Loading Failing for Non-Library Podcasts
**Problem**: When a podcast was removed from library, the old library ID became invalid and episodes wouldn't load.
**Solution**: Added fallback logic to try loading episodes via provider mappings when the primary ID fails.
**Files Modified**:
- `lib/screens/podcast_detail_screen.dart` - Added fallback episode loading
### 4. Search Filtering Rework
**Problem**: Server-side provider filtering was being applied to search results when it should only apply when the "in library" toggle is enabled.
**Solution**:
- Removed `filterSearchResults()` calls from search methods
- Added client-side `_filterByEnabledProviders()` helper
- Applied filtering only when `_libraryOnly` toggle is ON
**Files Modified**:
- `lib/providers/music_assistant_provider.dart` - Removed filterSearchResults calls
- `lib/screens/search_screen.dart` - Added client-side filtering
### 5. Library Buttons Not Updating Immediately
**Problem**: Add/remove library buttons on detail screens waited for API response before updating, causing perceived lag.
**Solution**: Implemented optimistic updates - update UI immediately, revert on failure.
**Files Modified**:
- `lib/screens/podcast_detail_screen.dart`
- `lib/screens/album_details_screen.dart`
- `lib/screens/artist_details_screen.dart`
## Code Changes
### ProviderMapping.inLibrary Field
```dart
// lib/models/media_item.dart
class ProviderMapping {
final String itemId;
final String providerDomain;
final String providerInstance;
final bool available;
final Map<String, dynamic>? audioFormat;
final bool inLibrary; // NEW: indicates ownership
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
return ProviderMapping(
// ... other fields ...
inLibrary: json['in_library'] == true || json['in_library'] == 1,
);
}
}
```
### Podcast Provider Counting
```dart
// lib/providers/music_assistant_provider.dart
Map<String, int> getProvidersWithPodcasts() {
final counts = <String, int>{};
for (final item in _podcasts) {
final mappings = item.providerMappings;
if (mappings != null) {
for (final mapping in mappings) {
// Only count if this provider "owns" the item
if (mapping.inLibrary) {
final instanceId = mapping.providerInstance;
if (instanceId.isNotEmpty) {
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
}
}
}
}
}
return counts;
}
```
### Optimistic Update Pattern
```dart
// Example from podcast_detail_screen.dart
Future<void> _toggleLibrary() async {
final newState = !_isInLibrary;
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.addedToLibrary)),
);
}
// Fire and forget - API call happens in background
maProvider.addToLibrary(...).catchError((e) {
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
}
```
### Client-Side Search Filtering
```dart
// lib/screens/search_screen.dart
Map<String, List<MediaItem>> _filterByEnabledProviders(
Map<String, List<MediaItem>> results,
Set<String> enabledProviders,
) {
if (enabledProviders.isEmpty) return results;
bool isItemAllowed(MediaItem item) {
final mappings = item.providerMappings;
if (mappings == null || mappings.isEmpty) return true;
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
}
return {
for (final entry in results.entries)
entry.key: entry.value.where(isItemAllowed).toList(),
};
}
// Applied when setting results:
if (_libraryOnly) {
final enabledProviders = provider.enabledProviderIds.toSet();
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
}
```
## Provider Capabilities Update
```dart
// lib/models/provider_instance.dart
static const Map<String, Set<String>> providerCapabilities = {
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'audiobookshelf': {'audiobooks', 'podcasts'},
'itunes_podcasts': {'podcasts'}, // NEW
'tunein': {'radio', 'podcasts'},
// ... other providers
};
```
## Summary of All Modified Files
| File | Changes |
|------|---------|
| `lib/models/media_item.dart` | Added `inLibrary` field to ProviderMapping |
| `lib/models/provider_instance.dart` | Added `itunes_podcasts`, podcasts to capabilities |
| `lib/providers/music_assistant_provider.dart` | Updated podcast counting, removed filterSearchResults |
| `lib/screens/new_library_screen.dart` | Updated podcast filtering with inLibrary check |
| `lib/screens/search_screen.dart` | Added client-side filtering for in-library mode |
| `lib/screens/podcast_detail_screen.dart` | Optimistic updates, fallback episode loading |
| `lib/screens/album_details_screen.dart` | Optimistic updates for library button |
| `lib/screens/artist_details_screen.dart` | Optimistic updates for library button |
## Key Learnings
1. **inLibrary field**: Music Assistant's provider mappings include an `in_library` field that indicates whether the user added the item from that specific provider account. This is essential for accurate per-provider filtering.
2. **iTunes podcast provider**: The MA iTunes podcast provider is for search only - it doesn't properly store items in the library. Users should add podcasts from streaming providers like Spotify instead.
3. **Optimistic updates**: For better UX, library toggle buttons should update immediately and only revert if the API call fails, rather than waiting for API response.
4. **Search vs Library filtering**: Server-side provider filtering should only apply to library views, not search results (unless the "in library" toggle is enabled).
@@ -0,0 +1,263 @@
# Provider Filtering Improvements
## Date: January 17, 2026
## Overview
This session focused on improving the provider filtering system across the app, building on the per-provider sync implementation. Key improvements include a cleaner capability-based architecture, fixing filter visibility bugs, and extending filtering to the audiobooks library.
---
## Changes Made
### 1. Provider Capability Mapping
**File:** `lib/models/provider_instance.dart`
Replaced the clunky item-count-based filtering with a static capability map that defines what content types each provider domain supports.
```dart
static const Map<String, Set<String>> providerCapabilities = {
// Music streaming services
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
'ytmusic': {'artists', 'albums', 'tracks', 'playlists'},
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
'apple_music': {'artists', 'albums', 'tracks', 'playlists'},
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
// Self-hosted media servers (can have music and audiobooks)
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
// Music-only servers
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
// Audiobook-only providers
'audiobookshelf': {'audiobooks'},
// Radio providers
'tunein': {'radio'},
'radiobrowser': {'radio'},
// Player/system providers (no library content)
'snapcast': <String>{},
'fully_kiosk': <String>{},
};
```
**Added helper methods:**
```dart
Set<String> get supportedContentTypes => providerCapabilities[domain] ?? <String>{};
bool supportsContentType(String category) => supportedContentTypes.contains(category);
```
**Benefits:**
- Radio providers never show in Artists tab (by design, not by count)
- Works immediately, even with empty libraries
- Disabled providers still show in menu (if they support the content type)
- Clean separation of concerns
---
### 2. Updated Provider Filtering in MusicAssistantProvider
**File:** `lib/providers/music_assistant_provider.dart`
Updated `getRelevantProvidersForCategory()` to filter by capability:
```dart
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
// ... get counts ...
final result = <(ProviderInstance, int)>[];
for (final provider in _availableMusicProviders) {
// Only include providers that support this content type
if (provider.supportsContentType(category)) {
final count = counts[provider.instanceId] ?? 0;
result.add((provider, count));
}
}
// ...
}
```
---
### 3. Simplified Library Menu Provider Filter
**File:** `lib/screens/new_library_screen.dart`
Simplified the options menu overlay to show all providers that support the current category:
**Before (clunky):**
```dart
if (widget.relevantProviders.where((p) {
final hasItems = p.$2 > 0;
final isDisabledByUser = widget.enabledProviderIds.isNotEmpty &&
!widget.enabledProviderIds.contains(p.$1.instanceId);
return hasItems || isDisabledByUser;
}).length > 1) ...[
```
**After (clean):**
```dart
// relevantProviders is pre-filtered by capability
if (widget.relevantProviders.length > 1) ...[
// Show all providers that support this category
...widget.relevantProviders.map((providerData) {
```
---
### 4. Removed Provider Toggles from Settings Screen
**File:** `lib/screens/settings_screen.dart`
Removed the "Music Providers" section entirely since provider filtering is now handled in the library menu:
- Removed `_providerEnabled` state variable
- Removed provider loading code from `_loadSettings()`
- Removed the entire Music Providers UI section (~100 lines)
**Rationale:** Single source of truth - provider toggles now only appear in the library menu where they're contextually relevant.
---
### 5. Fixed Audiobooks Library Provider Filtering
**File:** `lib/screens/new_library_screen.dart`
The audiobooks tabs were not filtering by provider - they loaded data once and displayed it without responding to provider changes.
**Fixed `_buildBooksAuthorsTab()`:**
```dart
final syncService = SyncService.instance;
final enabledProviders = maProvider.enabledProviderIds.toSet();
// Client-side filtering using SyncService source tracking
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
: List<Audiobook>.from(_audiobooks);
// Filter by favorites if enabled
if (_showFavoritesOnly) {
audiobooks = audiobooks.where((a) => a.favorite == true).toList();
}
// Group filtered audiobooks by author (dynamic, not cached)
final groupedByAuthor = <String, List<Audiobook>>{};
for (final book in audiobooks) {
final authorName = book.authorsString;
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
}
```
**Fixed `_buildAllBooksTab()`:**
- Same provider filtering pattern
- Dynamic sorting after filtering
- Updated PageStorageKey to include provider count for proper rebuild
---
## Bug Fixes
### Provider Filter Menu Disappearing
**Problem:** When providers were deselected, the background sync would clear the cache and only fetch data for enabled providers. Disabled providers then had 0 items, and the menu required `> 1 providers WITH items` to show. The entire Providers section would disappear.
**Solution:** Changed to capability-based filtering. The menu now shows all providers that support the category, regardless of item count.
### Audiobooks Not Filtering by Provider
**Problem:** Audiobooks tabs loaded data once into `_audiobooks` and displayed it without provider filtering. Toggling providers in the menu had no effect.
**Solution:** Added inline provider filtering during build (same pattern as music tabs).
---
## Files Changed
| File | Changes |
|------|---------|
| `lib/models/provider_instance.dart` | Added `providerCapabilities` map and helper methods |
| `lib/providers/music_assistant_provider.dart` | Updated `getRelevantProvidersForCategory()` to filter by capability |
| `lib/screens/new_library_screen.dart` | Simplified menu filter logic, added audiobook provider filtering |
| `lib/screens/settings_screen.dart` | Removed Music Providers section |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Provider Capability Map │
│ (ProviderInstance.providerCapabilities) │
│ │
│ spotify → {artists, albums, tracks, playlists, audiobooks} │
│ audiobookshelf → {audiobooks} │
│ tunein → {radio} │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ getRelevantProvidersForCategory(category) │
│ │
│ - Filters _availableMusicProviders by capability │
│ - Returns only providers that support the category │
│ - Includes item counts for display │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Library Menu (Options) │
│ │
│ - Shows providers section if > 1 providers support category │
│ - All relevant providers shown (can toggle on/off) │
│ - Instant UI update via setState() │
│ - Background sync after 1.5s debounce │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Tab Build Methods │
│ │
│ - Get enabledProviderIds from MusicAssistantProvider │
│ - Filter data using SyncService source tracking │
│ - Display filtered results │
└─────────────────────────────────────────────────────────────────┘
```
---
## Known Limitations
1. **Series Tab:** The Series tab in audiobooks doesn't filter by provider yet. Series are fetched separately as `AudiobookSeries` objects and don't have the same source tracking as individual audiobooks.
2. **First Sync Required:** Source tracking only works after at least one sync with providers enabled.
---
## Testing Checklist
- [x] Music tabs filter by provider correctly
- [x] Provider menu shows only relevant providers per category
- [x] Radio providers don't appear in Artists/Albums tabs
- [x] Spotify appears in Audiobooks tab (has audiobooks)
- [x] Audiobooks Authors tab filters by provider
- [x] Audiobooks All Books tab filters by provider
- [ ] Audiobooks Series tab filters by provider (not implemented)
- [x] Disabled providers can be re-enabled from menu
- [x] Settings screen no longer has provider toggles
---
## Related Documentation
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Original per-provider sync implementation
Binary file not shown.
+15
View File
@@ -28,4 +28,19 @@ class HeroTags {
/// Podcast cover hero tag prefix
static const String podcastCover = 'podcast_cover_';
/// Podcast title hero tag prefix
static const String podcastTitle = 'podcast_title_';
/// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_';
/// Playlist title hero tag prefix
static const String playlistTitle = 'playlist_title_';
/// Radio station cover hero tag prefix
static const String radioCover = 'radio_cover_';
/// Radio station title hero tag prefix
static const String radioTitle = 'radio_title_';
}
+3
View File
@@ -31,6 +31,9 @@ class Timings {
/// Search debounce delay
static const Duration searchDebounce = Duration(milliseconds: 500);
/// Navigation debounce to prevent double-tap navigation
static const Duration navigationDebounce = Duration(milliseconds: 500);
/// Delay after track change before updating state
static const Duration trackChangeDelay = Duration(milliseconds: 500);
}
+53 -8
View File
@@ -78,6 +78,10 @@ class LibraryCache extends Table {
/// Whether this item was deleted on the server
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
/// Provider instance IDs that provided this item (JSON array)
/// Used for client-side filtering by source provider
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
@override
Set<Column> get primaryKey => {cacheKey};
}
@@ -191,7 +195,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 5;
int get schemaVersion => 6;
@override
MigrationStrategy get migration {
@@ -226,6 +230,10 @@ class AppDatabase extends _$AppDatabase {
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player ON cached_queue (player_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player_position ON cached_queue (player_id, position)');
}
// Migration from v5 to v6: Add source_providers column for client-side filtering
if (from < 6) {
await customStatement("ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'");
}
},
);
}
@@ -368,20 +376,57 @@ class AppDatabase extends _$AppDatabase {
// ============================================
/// Cache a library item
/// [sourceProvider] - the provider instance ID that provided this item (for filtering)
Future<void> cacheItem({
required String itemType,
required String itemId,
required String data,
String? sourceProvider,
}) async {
final cacheKey = '${itemType}_$itemId';
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: itemType,
itemId: itemId,
data: data,
lastSynced: DateTime.now(),
));
// If sourceProvider is specified, we need to merge with existing providers
if (sourceProvider != null) {
final existing = await getCachedItem(itemType, itemId);
List<String> providers = [];
if (existing != null) {
try {
final existingProviders = existing.sourceProviders;
if (existingProviders.isNotEmpty && existingProviders != '[]') {
providers = List<String>.from(
(await Future.value(existingProviders))
.replaceAll('[', '')
.replaceAll(']', '')
.replaceAll('"', '')
.split(',')
.where((s) => s.trim().isNotEmpty)
.map((s) => s.trim()),
);
}
} catch (_) {}
}
if (!providers.contains(sourceProvider)) {
providers.add(sourceProvider);
}
final sourceProvidersJson = '[${providers.map((p) => '"$p"').join(',')}]';
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: itemType,
itemId: itemId,
data: data,
lastSynced: DateTime.now(),
sourceProviders: Value(sourceProvidersJson),
));
} else {
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: itemType,
itemId: itemId,
data: data,
lastSynced: DateTime.now(),
));
}
}
/// Get cached items by type
File diff suppressed because it is too large Load Diff
+1
View File
@@ -203,6 +203,7 @@
"english": "Englisch",
"german": "Deutsch",
"spanish": "Spanisch",
"french": "Französisch",
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
"sortAlphabetically": "Alphabetisch sortieren",
+215
View File
@@ -251,6 +251,21 @@
"description": "Home screen section title"
},
"favoritePlaylists": "Favorite Playlists",
"@favoritePlaylists": {
"description": "Home screen section title"
},
"favoriteRadioStations": "Favorite Radio Stations",
"@favoriteRadioStations": {
"description": "Home screen section title"
},
"favoritePodcasts": "Favorite Podcasts",
"@favoritePodcasts": {
"description": "Home screen section title"
},
"searchMusic": "Search music...",
"@searchMusic": {
"description": "Search placeholder text"
@@ -1045,6 +1060,11 @@
"description": "Spanish language"
},
"french": "French",
"@french": {
"description": "French language"
},
"noTracksInPlaylist": "No tracks in playlist",
"sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year",
@@ -1062,6 +1082,9 @@
"showFavoriteAlbums": "Show a row of your favorite albums",
"showFavoriteArtists": "Show a row of your favorite artists",
"showFavoriteTracks": "Show a row of your favorite tracks",
"showFavoritePlaylists": "Show a row of your favorite playlists",
"showFavoriteRadioStations": "Show a row of your favorite radio stations",
"showFavoritePodcasts": "Show a row of your favorite podcasts",
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
"addedToFavorites": "Added to favorites",
@@ -1117,6 +1140,10 @@
"audiobookLibraries": "Audiobook Libraries",
"chooseAudiobookLibraries": "Choose which Audiobookshelf libraries to include",
"unknownLibrary": "Unknown Library",
"musicProviders": "Music Providers",
"musicProvidersDescription": "Choose which accounts to show in your library",
"libraryRefreshing": "Refreshing library...",
"cannotDisableLastProvider": "Cannot disable the last provider",
"noSeriesFound": "No Series Found",
"ensembleBugReport": "Ensemble Bug Report",
"byOwner": "By {owner}",
@@ -1378,5 +1405,193 @@
"type": "String"
}
}
},
"noTracks": "No tracks",
"@noTracks": {
"description": "Empty state title when no tracks exist in library"
},
"addTracksHint": "Add some music to your library to see tracks here",
"@addTracksHint": {
"description": "Hint for adding tracks to library"
},
"noFavoriteRadioStations": "No favorite radio stations",
"@noFavoriteRadioStations": {
"description": "Empty state when no favorite radio stations"
},
"longPressRadioHint": "Long-press a station and tap the heart to add it to favorites",
"@longPressRadioHint": {
"description": "Hint for favoriting radio stations"
},
"noFavoritePodcasts": "No favorite podcasts",
"@noFavoritePodcasts": {
"description": "Empty state when no favorite podcasts"
},
"longPressPodcastHint": "Long-press a podcast and tap the heart to add it to favorites",
"@longPressPodcastHint": {
"description": "Hint for favoriting podcasts"
},
"failedToPlayRadioStation": "Failed to play radio station: {error}",
"@failedToPlayRadioStation": {
"description": "Error when playing radio station fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"itemAlreadyInLibrary": "Item is already in library",
"@itemAlreadyInLibrary": {
"description": "Message when trying to add item already in library"
},
"failedToAddToLibrary": "Failed to add to library",
"@failedToAddToLibrary": {
"description": "Error when adding to library fails"
},
"cannotFindLibraryId": "Cannot find library ID for removal",
"@cannotFindLibraryId": {
"description": "Error when library ID not found for removal"
},
"failedToRemoveFromLibrary": "Failed to remove from library",
"@failedToRemoveFromLibrary": {
"description": "Error when removing from library fails"
},
"failedToPlayAlbum": "Failed to play album: {error}",
"@failedToPlayAlbum": {
"description": "Error when playing album fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"playingOnPlayer": "Playing {name} on {playerName}",
"@playingOnPlayer": {
"description": "Status when playing item on specific player",
"placeholders": {
"name": {
"type": "String"
},
"playerName": {
"type": "String"
}
}
},
"failedToPlayPlaylist": "Failed to play playlist: {error}",
"@failedToPlayPlaylist": {
"description": "Error when playing playlist fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToPlayAudiobook": "Failed to play audiobook: {error}",
"@failedToPlayAudiobook": {
"description": "Error when playing audiobook fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"noOtherPlayersAvailable": "No other players available",
"@noOtherPlayersAvailable": {
"description": "Message when no other players available for transfer"
},
"transferQueue": "Transfer queue",
"@transferQueue": {
"description": "Tooltip for transfer queue button"
},
"clearQueue": "Clear queue",
"@clearQueue": {
"description": "Tooltip for clear queue button"
},
"syncingPlayer": "Syncing {playerName}...",
"@syncingPlayer": {
"description": "Status when syncing player",
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"switchPlayer": "Switch Player",
"@switchPlayer": {
"description": "Label for switch player action"
},
"logsCopiedToClipboard": "Logs copied to clipboard",
"@logsCopiedToClipboard": {
"description": "Confirmation when logs copied"
},
"auto": "Auto",
"@auto": {
"description": "Auto option for language selection"
},
"transferQueueTo": "Transfer Queue To",
"@transferQueueTo": {
"description": "Title for transfer queue dialog"
},
"queueTransferredTo": "Queue transferred to {playerName}",
"@queueTransferredTo": {
"description": "Success message when queue transferred",
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"failedToTransferQueue": "Failed to transfer queue: {error}",
"@failedToTransferQueue": {
"description": "Error when queue transfer fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToLoadQueue": "Failed to load queue: {error}",
"@failedToLoadQueue": {
"description": "Error when queue loading fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"transferTo": "Transfer to...",
"@transferTo": {
"description": "Label for transfer destination selection"
},
"transferQueueToPlayer": "Transfer queue to another player",
"@transferQueueToPlayer": {
"description": "Tooltip for transfer queue button"
}
}
+1
View File
@@ -360,6 +360,7 @@
"english": "Inglés",
"german": "Alemán",
"spanish": "Español",
"french": "Francés",
"noTracksInPlaylist": "No hay canciones en la lista",
"sortAlphabetically": "Ordenar alfabéticamente",
"sortByYear": "Ordenar por año",
+1400
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+80 -12
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -33,6 +34,10 @@ final _logger = DebugLogger();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize SharedPreferences cache early for performance
await SettingsService.initialize();
_logger.log('⚡ SharedPreferences cached');
// Initialize local database
await DatabaseService.instance.initialize();
_logger.log('💾 Database initialized');
@@ -71,17 +76,42 @@ Future<void> main() async {
DeviceOrientation.portraitDown,
]);
// Set system UI overlay style
// Set initial system UI overlay style based on platform brightness
// SystemUIWrapper will update this dynamically when theme changes
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
final isDark = platformBrightness == Brightness.dark;
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Color(0xFF1a1a1a),
systemNavigationBarIconBrightness: Brightness.light,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
runApp(const MusicAssistantApp());
// Set up Flutter error boundary to catch and log widget build errors
FlutterError.onError = (FlutterErrorDetails details) {
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
// Still report to Flutter's default handler in debug mode
FlutterError.presentError(details);
};
// Catch errors from the platform dispatcher (platform channel errors)
PlatformDispatcher.instance.onError = (error, stack) {
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
return true; // Handled
};
// Wrap app in zone to catch async errors
runZonedGuarded(
() => runApp(const MusicAssistantApp()),
(error, stackTrace) {
_logger.error('Uncaught async error: $error', context: 'ZoneError');
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
},
);
}
class MusicAssistantApp extends StatefulWidget {
@@ -176,7 +206,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
@override
Future<bool> didPopRoute() async {
// Intercept back button at app level - runs BEFORE Navigator processes it
// Priority order: device list > expanded player > normal navigation
// Priority order: device list > queue panel > expanded player > normal navigation
// Device list has highest priority - dismiss it first
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
@@ -184,6 +214,14 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
return true; // We handled it, don't let Navigator process it
}
// Queue panel second - close it before collapsing player
// Use target state (not animation value) to handle rapid open-close timing
// Use withHaptic: false because Android back gesture provides system haptic
if (GlobalPlayerOverlay.isQueuePanelTargetOpen) {
GlobalPlayerOverlay.closeQueuePanel(withHaptic: false);
return true; // We handled it, don't let Navigator process it
}
// Then check expanded player
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
@@ -229,10 +267,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
ColorScheme? darkColorScheme;
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
// Use system color schemes
// Use system color schemes, but override background to match app design
// Material You doesn't set background consistently, causing black screen issues
final (light, dark) = snapshot.data!;
lightColorScheme = light;
darkColorScheme = dark;
lightColorScheme = light.copyWith(
surface: light.surface,
background: const Color(0xFFFAFAFA), // App's preferred light background
);
darkColorScheme = dark.copyWith(
surface: const Color(0xFF2a2a2a), // App's preferred dark surface
background: const Color(0xFF1a1a1a), // App's preferred dark background
);
} else {
// Use custom color from theme provider
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
@@ -256,6 +301,29 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
],
supportedLocales: S.supportedLocales,
locale: localeProvider.locale,
// Fallback to English when locale is not supported
localeListResolutionCallback: (locales, supportedLocales) {
// If user has set a specific locale, try to use it
if (localeProvider.locale != null) {
for (final supported in supportedLocales) {
if (supported.languageCode == localeProvider.locale!.languageCode) {
return supported;
}
}
}
// Try to match system locales
if (locales != null) {
for (final locale in locales) {
for (final supported in supportedLocales) {
if (supported.languageCode == locale.languageCode) {
return supported;
}
}
}
}
// Fallback to English (not German)
return const Locale('en');
},
themeMode: themeProvider.themeMode,
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
@@ -332,8 +400,8 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark
? widget.darkColorScheme.background
: widget.lightColorScheme.background,
? widget.darkColorScheme.surface
: widget.lightColorScheme.surface,
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
+8
View File
@@ -16,6 +16,10 @@ class ProviderMapping {
final String providerInstance;
final bool available;
final Map<String, dynamic>? audioFormat;
/// Whether this item is in the library for this specific provider instance.
/// True = this provider "owns" the item (user added it from this account)
/// False = item exists but wasn't added from this provider
final bool inLibrary;
ProviderMapping({
required this.itemId,
@@ -23,6 +27,7 @@ class ProviderMapping {
required this.providerInstance,
required this.available,
this.audioFormat,
this.inLibrary = true,
});
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
@@ -32,6 +37,8 @@ class ProviderMapping {
providerInstance: json['provider_instance'] as String? ?? '',
available: json['available'] as bool? ?? true,
audioFormat: json['audio_format'] as Map<String, dynamic>?,
// Parse in_library field - MA uses 1/0 or true/false
inLibrary: json['in_library'] == true || json['in_library'] == 1,
);
}
@@ -42,6 +49,7 @@ class ProviderMapping {
'provider_instance': providerInstance,
'available': available,
if (audioFormat != null) 'audio_format': audioFormat,
'in_library': inLibrary,
};
}
}
+128
View File
@@ -0,0 +1,128 @@
/// Represents a music provider instance from Music Assistant.
///
/// Each provider (like Spotify, Tidal) can have multiple instances
/// (e.g., two different Spotify accounts). This class represents
/// a single provider instance with its unique ID.
class ProviderInstance {
/// Unique identifier for this provider instance (e.g., "spotify--abc123")
final String instanceId;
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Display name for this instance (e.g., "Spotify (John's Account)")
final String name;
/// Whether this provider is currently available/connected
final bool available;
ProviderInstance({
required this.instanceId,
required this.domain,
required this.name,
required this.available,
});
/// Provider capabilities - maps domain to supported content types
/// This determines which tabs/categories show which providers
static const Map<String, Set<String>> providerCapabilities = {
// Music streaming services (support artists, albums, tracks, playlists)
// Spotify also has audiobooks and podcasts
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
// Self-hosted media servers (can have music, audiobooks, and podcasts)
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
// Music-only servers
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
// Audiobook and podcast providers
'audiobookshelf': {'audiobooks', 'podcasts'},
'itunes_podcasts': {'podcasts'},
// Radio providers (tunein also has podcasts)
'tunein': {'radio', 'podcasts'},
'radiobrowser': {'radio'},
// Player/system providers (no library content)
'snapcast': <String>{},
'fully_kiosk': <String>{},
};
/// Known music provider domains that provide library content
static const Set<String> musicProviderDomains = {
// Streaming services
'spotify',
'tidal',
'qobuz',
'deezer',
'ytmusic',
'soundcloud',
'apple_music',
'amazon_music',
// Self-hosted / local
'plex',
'subsonic',
'opensubsonic',
'jellyfin',
'emby',
'navidrome',
'audiobookshelf',
'filesystem',
'itunes_podcasts',
// Other
'tunein',
'radiobrowser',
'snapcast',
'fully_kiosk',
};
/// Whether this is a music provider (vs. player provider, metadata provider, etc.)
bool get isMusicProvider => musicProviderDomains.contains(domain);
/// Get supported content types for this provider
Set<String> get supportedContentTypes =>
providerCapabilities[domain] ?? <String>{};
/// Check if this provider supports a specific content type/category
bool supportsContentType(String category) =>
supportedContentTypes.contains(category);
factory ProviderInstance.fromJson(Map<String, dynamic> json) {
return ProviderInstance(
instanceId: json['instance_id'] as String? ?? '',
domain: json['domain'] as String? ?? json['type'] as String? ?? '',
name: json['name'] as String? ?? json['instance_id'] as String? ?? 'Unknown',
available: json['available'] as bool? ?? true,
);
}
Map<String, dynamic> toJson() {
return {
'instance_id': instanceId,
'domain': domain,
'name': name,
'available': available,
};
}
@override
String toString() => 'ProviderInstance($instanceId: $name)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProviderInstance &&
runtimeType == other.runtimeType &&
instanceId == other.instanceId;
@override
int get hashCode => instanceId.hashCode;
}
-258
View File
@@ -1,258 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/database_service.dart';
import '../services/profile_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/auth/auth_manager.dart';
import '../services/cache_service.dart';
/// Provider for managing connection state to Music Assistant server
/// Handles WebSocket connection, authentication, and reconnection
class ConnectionProvider with ChangeNotifier {
MusicAssistantAPI? _api;
final AuthManager _authManager = AuthManager();
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService = CacheService();
MAConnectionState _connectionState = MAConnectionState.disconnected;
String? _serverUrl;
String? _error;
// Callbacks for post-connection initialization
VoidCallback? onConnected;
VoidCallback? onAuthenticated;
VoidCallback? onDisconnected;
MAConnectionState get connectionState => _connectionState;
String? get serverUrl => _serverUrl;
String? get error => _error;
bool get isConnected => _connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager;
CacheService get cacheService => _cacheService;
ConnectionProvider() {
_initialize();
}
Future<void> _initialize() async {
_serverUrl = await SettingsService.getServerUrl();
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
await _restoreAuthCredentials();
await connectToServer(_serverUrl!);
}
}
/// Restore auth credentials from persistent storage
Future<void> _restoreAuthCredentials() async {
final savedCredentials = await SettingsService.getAuthCredentials();
if (savedCredentials != null) {
_logger.log('🔐 Restoring saved auth credentials...');
_authManager.deserializeCredentials(savedCredentials);
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}');
} else {
_logger.log('🔐 No saved auth credentials found');
}
}
/// Handle Music Assistant native authentication after WebSocket connection
Future<bool> handleMaAuthentication() async {
if (_api == null) return false;
try {
// First, try stored MA token
final storedToken = await SettingsService.getMaAuthToken();
if (storedToken != null) {
_logger.log('🔐 Trying stored MA token...');
final success = await _api!.authenticateWithToken(storedToken);
if (success) {
_logger.log('✅ MA authentication with stored token successful');
await _fetchAndSetUserProfileName();
return true;
}
_logger.log('⚠️ Stored MA token invalid, clearing...');
await SettingsService.clearMaAuthToken();
}
// No valid token - try stored credentials
final username = await SettingsService.getUsername();
final password = await SettingsService.getPassword();
if (username != null && password != null && username.isNotEmpty && password.isNotEmpty) {
_logger.log('🔐 Trying stored credentials...');
final accessToken = await _api!.loginWithCredentials(username, password);
if (accessToken != null) {
_logger.log('✅ MA login with stored credentials successful');
final longLivedToken = await _api!.createLongLivedToken();
if (longLivedToken != null) {
await SettingsService.setMaAuthToken(longLivedToken);
_logger.log('✅ Saved new long-lived MA token');
} else {
await SettingsService.setMaAuthToken(accessToken);
}
await _fetchAndSetUserProfileName();
return true;
}
}
_logger.log('❌ MA authentication failed - no valid token or credentials');
return false;
} catch (e) {
_logger.log('❌ MA authentication error: $e');
return false;
}
}
/// Fetch user profile from MA and set owner name + create/activate local profile
Future<void> _fetchAndSetUserProfileName() async {
if (_api == null) return;
try {
final userInfo = await _api!.getCurrentUserInfo();
if (userInfo == null) {
_logger.log('⚠️ Could not fetch user profile');
return;
}
final displayName = userInfo['display_name'] as String?;
final username = userInfo['username'] as String?;
_logger.log('🔍 Profile data: display_name="$displayName", username="$username"');
final profileName = (displayName != null && displayName.isNotEmpty)
? displayName
: username;
if (profileName != null && profileName.isNotEmpty) {
await SettingsService.setOwnerName(profileName);
_logger.log('✅ Set owner name from MA profile: $profileName');
// Create/activate profile in local database
if (DatabaseService.instance.isInitialized && username != null) {
await ProfileService.instance.onMaAuthenticated(
username: username,
displayName: displayName,
);
_logger.log('✅ Local profile activated: $username');
}
} else {
_logger.log('⚠️ No valid name in profile');
}
} catch (e) {
_logger.log('⚠️ Could not fetch user profile (non-fatal): $e');
}
}
Future<void> connectToServer(String serverUrl) async {
try {
_error = null;
_serverUrl = serverUrl;
await SettingsService.setServerUrl(serverUrl);
// Disconnect existing connection
await _api?.disconnect();
_api = MusicAssistantAPI(serverUrl, _authManager);
// Listen to connection state changes
_api!.connectionState.listen(
(state) async {
_connectionState = state;
notifyListeners();
if (state == MAConnectionState.connected) {
_logger.log('🔗 WebSocket connected to MA server');
if (_api!.authRequired && !_api!.isAuthenticated) {
_logger.log('🔐 MA auth required, attempting authentication...');
final authenticated = await handleMaAuthentication();
if (!authenticated) {
_logger.log('❌ MA authentication failed - stopping connection flow');
_error = 'Authentication required. Please log in again.';
notifyListeners();
return;
}
return;
}
// No auth required - notify connected
onConnected?.call();
} else if (state == MAConnectionState.authenticated) {
_logger.log('✅ MA authentication successful');
onAuthenticated?.call();
} else if (state == MAConnectionState.disconnected) {
// DON'T clear caches on disconnect - keep showing cached data
// This allows instant UI display when app resumes or reconnects
// Caches will be refreshed when connection is restored
onDisconnected?.call();
}
},
onError: (error) {
_logger.log('Connection state stream error: $error');
_connectionState = MAConnectionState.error;
notifyListeners();
},
);
await _api!.connect();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Connect to server');
_error = errorInfo.userMessage;
_connectionState = MAConnectionState.error;
_logger.log('Connection error: ${errorInfo.technicalMessage}');
notifyListeners();
rethrow;
}
}
Future<void> disconnect() async {
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
notifyListeners();
}
/// Clear all caches and state (for logout or server change)
void clearCachesOnLogout() {
_cacheService.clearAll();
notifyListeners();
}
/// Check connection and reconnect if needed (called when app resumes)
Future<void> checkAndReconnect() async {
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
if (_serverUrl == null) {
_logger.log('🔄 No server URL saved, skipping reconnect');
return;
}
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
try {
await connectToServer(_serverUrl!);
_logger.log('🔄 Reconnection successful');
} catch (e) {
_logger.log('🔄 Reconnection failed: $e');
}
} else {
_logger.log('🔄 Already connected, connection verified');
}
}
@override
void dispose() {
_api?.dispose();
super.dispose();
}
}
-392
View File
@@ -1,392 +0,0 @@
import 'package:flutter/foundation.dart';
import '../models/media_item.dart';
import '../services/music_assistant_api.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/cache_service.dart';
import '../services/settings_service.dart';
import '../constants/timings.dart';
/// Provider for managing library data (artists, albums, tracks, playlists)
class LibraryProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService;
MusicAssistantAPI? _api;
List<Artist> _artists = [];
List<Album> _albums = [];
List<Track> _tracks = [];
bool _isLoading = false;
String? _error;
// Search state persistence
String _lastSearchQuery = '';
Map<String, List<MediaItem>> _lastSearchResults = {
'artists': [],
'albums': [],
'tracks': [],
};
// Getters
List<Artist> get artists => _artists;
List<Album> get albums => _albums;
List<Track> get tracks => _tracks;
bool get isLoading => _isLoading;
String? get error => _error;
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
LibraryProvider(this._cacheService);
/// Initialize with API reference after connection
void initialize(MusicAssistantAPI api) {
_api = api;
}
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
_lastSearchQuery = query;
_lastSearchResults = results;
}
void clearSearchState() {
_lastSearchQuery = '';
_lastSearchResults = {
'artists': [],
'albums': [],
'tracks': [],
};
}
// ============================================================================
// HOME SCREEN DATA
// ============================================================================
/// Get recently played albums with caching
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached recent albums');
return _cacheService.getCachedRecentAlbums()!;
}
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
try {
_logger.log('🔄 Fetching fresh recent albums...');
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedRecentAlbums(albums);
return albums;
} catch (e) {
_logger.log('❌ Failed to fetch recent albums: $e');
return _cacheService.getCachedRecentAlbums() ?? [];
}
}
/// Get discover artists (random) with caching
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover artists');
return _cacheService.getCachedDiscoverArtists()!;
}
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
try {
_logger.log('🔄 Fetching fresh discover artists...');
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedDiscoverArtists(artists);
return artists;
} catch (e) {
_logger.log('❌ Failed to fetch discover artists: $e');
return _cacheService.getCachedDiscoverArtists() ?? [];
}
}
/// Get discover albums (random) with caching
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover albums');
return _cacheService.getCachedDiscoverAlbums()!;
}
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
try {
_logger.log('🔄 Fetching fresh discover albums...');
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
_cacheService.setCachedDiscoverAlbums(albums);
return albums;
} catch (e) {
_logger.log('❌ Failed to fetch discover albums: $e');
return _cacheService.getCachedDiscoverAlbums() ?? [];
}
}
/// Invalidate home screen cache (call on pull-to-refresh)
void invalidateHomeCache() {
_cacheService.invalidateHomeCache();
}
// ============================================================================
// LIBRARY DATA
// ============================================================================
Future<void> loadLibrary() async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 loadLibrary using albumArtistsOnly: $showOnlyArtistsWithAlbums');
final results = await Future.wait([
_api!.getArtists(limit: LibraryConstants.maxLibraryItems, albumArtistsOnly: showOnlyArtistsWithAlbums),
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
]);
_artists = results[0] as List<Artist>;
_albums = results[1] as List<Album>;
_tracks = results[2] as List<Track>;
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_artists = await _api!.getArtists(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
albumArtistsOnly: showOnlyArtistsWithAlbums,
);
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId}) async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
_albums = await _api!.getAlbums(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
artistId: artistId,
);
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
// ============================================================================
// DETAIL SCREEN DATA
// ============================================================================
/// Get album tracks with caching
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached album tracks for $cacheKey');
return _cacheService.getCachedAlbumTracks(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching fresh album tracks for $cacheKey...');
final tracks = await _api!.getAlbumTracks(provider, itemId);
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch album tracks: $e');
return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
}
}
/// Get playlist tracks with caching
Future<List<Track>> getPlaylistTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isPlaylistTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached playlist tracks for $cacheKey');
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
final tracks = await _api!.getPlaylistTracks(provider, itemId);
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch playlist tracks: $e');
return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
}
}
/// Get artist albums with caching
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
final cacheKey = artistName.toLowerCase();
if (_cacheService.isArtistAlbumsCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached artist albums for "$artistName"');
return _cacheService.getCachedArtistAlbums(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching albums for artist "$artistName"...');
final libraryAlbums = await _api!.getAlbums();
final artistAlbums = libraryAlbums.where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
}).toList();
final searchResults = await _api!.search(artistName);
final searchAlbums = searchResults['albums'] as List<MediaItem>? ?? [];
final providerAlbums = searchAlbums.whereType<Album>().where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
}).toList();
final allAlbums = <Album>[];
final seenNames = <String>{};
for (final album in [...artistAlbums, ...providerAlbums]) {
final key = album.name.toLowerCase();
if (!seenNames.contains(key)) {
seenNames.add(key);
allAlbums.add(album);
}
}
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
return allAlbums;
} catch (e) {
_logger.log('❌ Failed to fetch artist albums: $e');
return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
}
}
/// Invalidate album tracks cache
void invalidateAlbumTracksCache(String albumId) {
_cacheService.invalidateAlbumTracksCache(albumId);
}
/// Invalidate playlist tracks cache
void invalidatePlaylistTracksCache(String playlistId) {
_cacheService.invalidatePlaylistTracksCache(playlistId);
}
// ============================================================================
// SEARCH
// ============================================================================
/// Search with caching
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
final cacheKey = query.toLowerCase().trim();
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached search results for "$query"');
return _cacheService.getCachedSearchResults(cacheKey)!;
}
if (_api == null) {
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
}
try {
_logger.log('🔄 Searching for "$query"...');
final results = await _api!.search(query);
final cachedResults = <String, List<MediaItem>>{
'artists': results['artists'] ?? [],
'albums': results['albums'] ?? [],
'tracks': results['tracks'] ?? [],
};
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
return cachedResults;
} catch (e) {
_logger.log('❌ Search failed: $e');
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
}
}
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
if (_api == null) return {'artists': [], 'albums': [], 'tracks': []};
try {
return await _api!.search(query, libraryOnly: libraryOnly);
} catch (e) {
ErrorHandler.logError('Search', e);
return {'artists': [], 'albums': [], 'tracks': []};
}
}
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
if (_api == null) return [];
try {
return await _api!.getAlbumTracks(provider, itemId);
} catch (e) {
ErrorHandler.logError('Get album tracks', e);
return [];
}
}
void clearState() {
_artists = [];
_albums = [];
_tracks = [];
_isLoading = false;
_error = null;
clearSearchState();
notifyListeners();
}
}
+4
View File
@@ -31,6 +31,10 @@ class LocaleProvider extends ChangeNotifier {
return 'English';
case 'de':
return 'Deutsch';
case 'es':
return 'Español';
case 'fr':
return 'Français';
case null:
return 'System';
default:
+528 -70
View File
@@ -7,6 +7,7 @@ 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/player.dart';
import '../models/provider_instance.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
@@ -65,6 +66,7 @@ class MusicAssistantProvider with ChangeNotifier {
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
Track? _currentTrack;
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
String? _currentPodcastName; // Currently playing podcast's name (set when playing episode)
Timer? _playerStateTimer;
Timer? _notificationPositionTimer; // Updates notification position every second for remote players
@@ -113,6 +115,13 @@ class MusicAssistantProvider with ChangeNotifier {
// Empty list means all players are allowed
List<String> _playerFilter = [];
// User-controlled music provider filter (local settings in Ensemble)
// Empty list means all providers are enabled (no filtering)
List<String> _enabledProviderIds = [];
// Available music providers discovered from MA
List<ProviderInstance> _availableMusicProviders = [];
// ============================================================================
// GETTERS
// ============================================================================
@@ -133,6 +142,8 @@ class MusicAssistantProvider with ChangeNotifier {
// Raw unfiltered access (for internal use only)
List<Artist> get artistsUnfiltered => _artists;
List<Album> get albumsUnfiltered => _albums;
List<MediaItem> get podcastsUnfiltered => _podcasts;
List<MediaItem> get radioStationsUnfiltered => _radioStations;
bool get isLoading => _isLoading;
bool get isLoadingRadio => _isLoadingRadio;
bool get isLoadingPodcasts => _isLoadingPodcasts;
@@ -203,6 +214,210 @@ class MusicAssistantProvider with ChangeNotifier {
return players.where(isPlayerAllowedByFilter).toList();
}
// ============================================================================
// USER-CONTROLLED MUSIC PROVIDER FILTER
// ============================================================================
/// List of available music providers from MA
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
/// List of enabled provider instance IDs (empty = all enabled)
List<String> get enabledProviderIds => _enabledProviderIds;
/// Whether user has enabled specific providers (not all)
bool get hasUserProviderFilter => _enabledProviderIds.isNotEmpty;
/// Get provider IDs to pass to API calls
/// Returns null if all providers are enabled (no filtering needed)
/// Returns the list of enabled provider IDs otherwise
List<String>? get providerIdsForApiCalls {
if (_enabledProviderIds.isEmpty) return null; // All enabled
return _enabledProviderIds;
}
/// Check if a specific provider is enabled by the user
bool isProviderEnabled(String instanceId) {
if (_enabledProviderIds.isEmpty) return true; // All enabled by default
return _enabledProviderIds.contains(instanceId);
}
/// Get providers that have content in artists library (with item counts)
Map<String, int> getProvidersWithArtists() {
return _getProvidersWithCounts(_artists);
}
/// Get providers that have content in albums library (with item counts)
Map<String, int> getProvidersWithAlbums() {
return _getProvidersWithCounts(_albums);
}
/// Get providers that have content in tracks library (with item counts)
Map<String, int> getProvidersWithTracks() {
return _getProvidersWithCounts(_tracks);
}
/// Get providers that have content in playlists (with item counts)
Map<String, int> getProvidersWithPlaylists() {
return _getProvidersWithCounts(SyncService.instance.cachedPlaylists);
}
/// Get providers that have audiobooks (with item counts)
Map<String, int> getProvidersWithAudiobooks() {
return _getProvidersWithCounts(SyncService.instance.cachedAudiobooks);
}
/// Get providers that have radio stations (with item counts)
Map<String, int> getProvidersWithRadio() {
return _getProvidersWithCounts(_radioStations);
}
/// Get providers that have podcasts (with item counts)
/// Only counts mappings where inLibrary is true - this indicates which provider
/// the user actually added the podcast from (vs providers that can play it)
Map<String, int> getProvidersWithPodcasts() {
final counts = <String, int>{};
for (final item in _podcasts) {
final mappings = item.providerMappings;
if (mappings != null) {
for (final mapping in mappings) {
// Only count if this provider "owns" the item (user added it from this account)
if (mapping.inLibrary) {
final instanceId = mapping.providerInstance;
if (instanceId.isNotEmpty) {
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
}
}
}
}
}
return counts;
}
/// Internal helper to count items per provider from a list of media items
/// Only counts mappings where inLibrary is true - this indicates which provider
/// the user actually added the item from (vs providers that can play it)
Map<String, int> _getProvidersWithCounts<T extends MediaItem>(List<T> items) {
final counts = <String, int>{};
for (final item in items) {
final mappings = item.providerMappings;
if (mappings != null) {
for (final mapping in mappings) {
// Only count if this provider "owns" the item (user added it from this account)
if (mapping.inLibrary) {
final instanceId = mapping.providerInstance;
if (instanceId.isNotEmpty) {
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
}
}
}
}
}
return counts;
}
/// Get ProviderInstance objects for providers that have content in a category
/// Returns list of (ProviderInstance, itemCount) tuples, sorted by name
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
final Map<String, int> counts;
switch (category) {
case 'artists':
counts = getProvidersWithArtists();
break;
case 'albums':
counts = getProvidersWithAlbums();
break;
case 'tracks':
counts = getProvidersWithTracks();
break;
case 'playlists':
counts = getProvidersWithPlaylists();
break;
case 'audiobooks':
counts = getProvidersWithAudiobooks();
break;
case 'radio':
counts = getProvidersWithRadio();
break;
case 'podcasts':
counts = getProvidersWithPodcasts();
break;
default:
counts = {};
}
// Build list of providers that support this category, with their item counts
// Only include providers that have at least 1 item (hides providers not synced to library)
final result = <(ProviderInstance, int)>[];
final addedInstanceIds = <String>{};
for (final provider in _availableMusicProviders) {
// Only include providers that support this content type AND have items
if (provider.supportsContentType(category)) {
final count = counts[provider.instanceId] ?? 0;
if (count > 0) {
result.add((provider, count));
addedInstanceIds.add(provider.instanceId);
}
}
}
// Also include providers that have items but aren't in _availableMusicProviders
// (e.g., search-only providers like iTunes)
for (final entry in counts.entries) {
if (!addedInstanceIds.contains(entry.key) && entry.value > 0) {
// Create a synthetic ProviderInstance from the instance ID
// Format is typically "domain--uniqueId" (e.g., "itunes--abc123")
final instanceId = entry.key;
final domain = instanceId.contains('--')
? instanceId.split('--').first
: instanceId;
// Only add if this domain supports the category
final capabilities = ProviderInstance.providerCapabilities[domain];
if (capabilities != null && capabilities.contains(category)) {
result.add((
ProviderInstance(
instanceId: instanceId,
domain: domain,
name: _formatProviderName(domain),
available: true,
),
entry.value,
));
}
}
}
// Sort by name
result.sort((a, b) => a.$1.name.compareTo(b.$1.name));
return result;
}
/// Format a provider domain into a readable display name
String _formatProviderName(String domain) {
const names = {
'spotify': 'Spotify',
'tidal': 'Tidal',
'qobuz': 'Qobuz',
'deezer': 'Deezer',
'ytmusic': 'YouTube Music',
'soundcloud': 'SoundCloud',
'apple_music': 'Apple Music',
'amazon_music': 'Amazon Music',
'plex': 'Plex',
'jellyfin': 'Jellyfin',
'emby': 'Emby',
'subsonic': 'Subsonic',
'opensubsonic': 'OpenSubsonic',
'navidrome': 'Navidrome',
'audiobookshelf': 'Audiobookshelf',
'filesystem': 'Filesystem',
'itunes_podcasts': 'iTunes Podcasts',
'tunein': 'TuneIn',
'radiobrowser': 'Radio Browser',
};
return names[domain] ?? domain[0].toUpperCase() + domain.substring(1);
}
/// Whether library is syncing in background
bool get isSyncing => SyncService.instance.isSyncing;
@@ -333,14 +548,18 @@ class MusicAssistantProvider with ChangeNotifier {
}
/// Get the podcast name when playing a podcast episode
/// Returns the podcast name from metadata, album name, or artist name
/// Returns the podcast name from stored context, metadata, or fallbacks
String? get currentPodcastName {
if (!isPlayingPodcast || _currentTrack == null) return null;
// Try metadata first (if episode has parent podcast info)
// Primary source: stored podcast name from when episode was played
if (_currentPodcastName != null && _currentPodcastName!.isNotEmpty) {
return _currentPodcastName;
}
// Try metadata (if episode has parent podcast info from API)
final metadata = _currentTrack!.metadata;
if (metadata != null) {
// Check for podcast_name or podcast.name in metadata
if (metadata['podcast_name'] != null) {
return metadata['podcast_name'] as String;
}
@@ -351,22 +570,28 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
}
// Fall back to album name (often contains podcast name)
if (_currentTrack!.album != null && _currentTrack!.album!.name.isNotEmpty) {
return _currentTrack!.album!.name;
}
// Fall back to artist (sometimes the podcast name is set as artist)
if (_currentTrack!.artists != null && _currentTrack!.artists!.isNotEmpty) {
final artistName = _currentTrack!.artists!.first.name;
if (artistName != 'Unknown Artist' && artistName.isNotEmpty) {
return artistName;
}
}
// Fallbacks removed - album/artist contain episode name, not podcast name
// Return null to let UI show generic "Podcasts" label
return null;
}
/// Set the current podcast context (call when playing a podcast episode)
void setCurrentPodcastName(String? podcastName) {
_currentPodcastName = podcastName;
_logger.log('🎙️ Set current podcast name: $podcastName');
}
/// Clear the podcast context
void clearCurrentPodcastName() {
if (_currentPodcastName != null) {
_logger.log('🎙️ Cleared podcast context');
_currentPodcastName = null;
}
}
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => filterSearchResults(_lastSearchResults);
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager;
@@ -490,6 +715,12 @@ class MusicAssistantProvider with ChangeNotifier {
return getImageUrl(track, size: size);
}
/// Clear cached track for a player (e.g., after queue transfer)
void clearPlayerTrackCache(String playerId) {
_cacheService.clearCachedTrackForPlayer(playerId);
notifyListeners();
}
/// Number of pending offline actions
int get pendingOfflineActionsCount => OfflineActionQueue.instance.pendingCount;
@@ -933,6 +1164,83 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Load available music providers from MA and restore saved filter settings
Future<void> loadMusicProviders() async {
if (_api == null) return;
try {
_logger.log('🎵 Loading music providers...');
// Fetch available providers from MA
_availableMusicProviders = await _api!.getMusicProviders();
// Save discovered providers to settings
final discoveredList = _availableMusicProviders.map((p) => {
'instanceId': p.instanceId,
'domain': p.domain,
'name': p.name,
}).toList();
await SettingsService.setDiscoveredMusicProviders(discoveredList);
// Load saved enabled providers
final savedEnabled = await SettingsService.getEnabledMusicProviders();
if (savedEnabled != null) {
// Validate saved IDs against available providers
final availableIds = _availableMusicProviders.map((p) => p.instanceId).toSet();
_enabledProviderIds = savedEnabled.where((id) => availableIds.contains(id)).toList();
// If all saved providers are gone, reset to all enabled
if (_enabledProviderIds.isEmpty && savedEnabled.isNotEmpty) {
_enabledProviderIds = [];
await SettingsService.clearEnabledMusicProviders();
_logger.log('🔄 Reset provider filter - saved providers no longer available');
} else if (_enabledProviderIds.isNotEmpty) {
_logger.log('🔒 Loaded provider filter: ${_enabledProviderIds.length} providers enabled');
}
} else {
_enabledProviderIds = [];
_logger.log('🔓 No provider filter - all ${_availableMusicProviders.length} providers enabled');
}
notifyListeners();
} catch (e) {
_logger.log('⚠️ Could not load music providers (non-fatal): $e');
}
}
/// Toggle a specific music provider on/off
/// Uses debounce to allow multiple toggles before syncing
Future<bool> toggleProviderEnabled(String instanceId, bool enabled) async {
final allIds = _availableMusicProviders.map((p) => p.instanceId).toList();
// Don't allow disabling the last provider
if (!enabled && _enabledProviderIds.isNotEmpty && _enabledProviderIds.length <= 1) {
_logger.log('⚠️ Cannot disable last provider');
return false;
}
await SettingsService.toggleMusicProvider(instanceId, enabled, allIds);
// Reload the enabled providers
final savedEnabled = await SettingsService.getEnabledMusicProviders();
_enabledProviderIds = savedEnabled ?? [];
_logger.log('🔄 Provider filter updated: ${_enabledProviderIds.isEmpty ? "all enabled" : "${_enabledProviderIds.length} enabled"}');
// Clear discover caches so they get refetched with new filter
_cacheService.clearDiscoverCaches();
// Notify listeners IMMEDIATELY so UI rebuilds with client-side filtering
// This enables instant UI updates using cached data with source tracking
notifyListeners();
// NOTE: Debounced sync is handled by the UI layer (new_library_screen.dart)
// to avoid duplicate timers and ensure single point of control
// Return true to indicate UI should update
return true;
}
Future<void> _initializeAfterConnection() async {
if (_api == null) return;
@@ -945,6 +1253,9 @@ class MusicAssistantProvider with ChangeNotifier {
await _fetchUserSettings();
}
// Load available music providers and user's filter preferences
await loadMusicProviders();
await _tryAdoptGhostPlayer();
await _registerLocalPlayer();
await _loadAndSelectPlayers(coldStart: true);
@@ -2350,6 +2661,12 @@ class MusicAssistantProvider with ChangeNotifier {
clearCurrentAudiobook();
}
// Clear podcast context when switching to non-podcast media
if (mediaType != 'podcast_episode' && _currentPodcastName != null) {
_logger.log('🎙️ Media type changed to $mediaType - clearing podcast context');
clearCurrentPodcastName();
}
if (mediaType != 'flow_stream') {
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
final albumName = currentMedia['album'] as String?;
@@ -2416,17 +2733,22 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('📻 Found artist from artists array: $artistName');
}
// Check for stream_title which might contain "Artist - Title" format
// Check for stream_title which contains the actual now-playing metadata
// For radio, stream_title has "Artist - Title" format from the stream's ICY metadata
final streamTitle = currentMedia['stream_title'] as String?;
if (streamTitle != null && streamTitle.contains(' - ') && artistName == null) {
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artistName = parts[0].trim();
// If trackTitle is still the default or matches the stream_title, update it too
if (trackTitle == 'Unknown Track' || trackTitle == streamTitle) {
if (streamTitle != null && streamTitle.isNotEmpty) {
if (streamTitle.contains(' - ')) {
// Parse "Artist - Title" format
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artistName = parts[0].trim();
trackTitle = parts.sublist(1).join(' - ').trim();
_logger.log('📻 Parsed from stream_title: artist=$artistName, title=$trackTitle');
}
_logger.log('📻 Parsed artist from stream_title: $artistName, title: $trackTitle');
} else {
// No separator, use stream_title as the title
trackTitle = streamTitle;
_logger.log('📻 Using stream_title as title: $trackTitle');
}
}
}
@@ -2453,14 +2775,19 @@ class MusicAssistantProvider with ChangeNotifier {
// Only cache if we don't already have better data from queue
final existingTrack = _cacheService.getCachedTrackForPlayer(playerId);
// Check if existing track has proper artist (not Unknown Artist and not empty)
final existingArtist = existingTrack?.artistsString;
final existingHasProperArtist = existingTrack != null &&
existingTrack.artistsString != 'Unknown Artist' &&
!existingTrack.name.contains(' - ');
existingArtist != null &&
existingArtist != 'Unknown Artist' &&
existingArtist.trim().isNotEmpty;
final existingHasImage = existingTrack?.metadata?['images'] != null;
final newHasImage = metadata != null;
final newHasAlbum = albumName != null;
final existingHasAlbum = existingTrack?.album != null;
final newHasProperArtist = artistName != 'Unknown Artist';
// Check if new data has proper artist AND title (not malformed like "- Something")
final newTitleIsMalformed = trackTitle.startsWith('- ') || trackTitle.trim().isEmpty;
final newHasProperArtist = artistName != 'Unknown Artist' && !newTitleIsMalformed;
// For podcasts, album info is crucial (it's the podcast name)
// If new track has album but existing doesn't, prefer new or merge
@@ -2511,13 +2838,20 @@ class MusicAssistantProvider with ChangeNotifier {
}
// For selected player, _updatePlayerState() is already called above which fetches queue data
// Only update _currentTrack here if we don't have it yet OR if we have better album data for podcasts
// Update _currentTrack if:
// - We don't have it yet
// - Podcast with new album data
// - Radio with new stream metadata (artist changed from Unknown)
final currentHasUnknownArtist = _currentTrack?.artistsString == 'Unknown Artist' ||
_currentTrack?.artistsString == null;
final shouldUpdateCurrentTrack = _selectedPlayer != null &&
playerId == _selectedPlayer!.playerId &&
(_currentTrack == null || (isPodcastUri && newHasAlbum && _currentTrack?.album == null));
(_currentTrack == null ||
(isPodcastUri && newHasAlbum && _currentTrack?.album == null) ||
(isRadioUri && newHasProperArtist && currentHasUnknownArtist));
if (shouldUpdateCurrentTrack) {
_currentTrack = _cacheService.getCachedTrackForPlayer(playerId) ?? trackFromEvent;
_logger.log('📋 Updated _currentTrack for podcast with album: ${_currentTrack?.album?.name}');
_logger.log('📋 Updated _currentTrack: ${_currentTrack?.name} by ${_currentTrack?.artistsString}');
}
notifyListeners();
@@ -2554,15 +2888,19 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
// Check for stream_title with "Artist - Title" format
// Check for stream_title which contains the actual now-playing metadata
final streamTitle = currentMedia['stream_title'] as String?;
if (streamTitle != null && streamTitle.contains(' - ') && artist == null) {
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artist = parts[0].trim();
if (title == 'Unknown Track' || title == streamTitle) {
if (streamTitle != null && streamTitle.isNotEmpty) {
if (streamTitle.contains(' - ')) {
// Parse "Artist - Title" format
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artist = parts[0].trim();
title = parts.sublist(1).join(' - ').trim();
}
} else {
// No separator, use stream_title as the title
title = streamTitle;
}
}
}
@@ -2683,7 +3021,10 @@ class MusicAssistantProvider with ChangeNotifier {
try {
_logger.log('🔄 Fetching fresh discover artists...');
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
final artists = await _api!.getRandomArtists(
limit: LibraryConstants.defaultRecentLimit,
providerInstanceIds: providerIdsForApiCalls,
);
_cacheService.setCachedDiscoverArtists(artists);
return artists;
} catch (e) {
@@ -2702,7 +3043,10 @@ class MusicAssistantProvider with ChangeNotifier {
try {
_logger.log('🔄 Fetching fresh discover albums...');
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
final albums = await _api!.getRandomAlbums(
limit: LibraryConstants.defaultRecentLimit,
providerInstanceIds: providerIdsForApiCalls,
);
_cacheService.setCachedDiscoverAlbums(albums);
return albums;
} catch (e) {
@@ -2726,10 +3070,14 @@ class MusicAssistantProvider with ChangeNotifier {
/// Force a full library sync (for pull-to-refresh)
Future<void> forceLibrarySync() async {
if (_api == null) return;
if (_api == null) {
_logger.log('⚠️ forceLibrarySync: API is null, skipping');
return;
}
_logger.log('🔄 Forcing full library sync...');
await SyncService.instance.forceSync(_api!);
final providerIds = providerIdsForApiCalls;
_logger.log('🔄 Forcing full library sync with providers: ${providerIds ?? "ALL"}');
await SyncService.instance.forceSync(_api!, providerInstanceIds: providerIds);
// Update local lists from sync result
_albums = SyncService.instance.cachedAlbums;
@@ -2737,7 +3085,10 @@ class MusicAssistantProvider with ChangeNotifier {
// Also refresh tracks from API
try {
_tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems);
_tracks = await _api!.getTracks(
limit: LibraryConstants.maxLibraryItems,
providerInstanceIds: providerIdsForApiCalls,
);
} catch (e) {
_logger.log('⚠️ Failed to refresh tracks: $e');
}
@@ -2778,13 +3129,59 @@ class MusicAssistantProvider with ChangeNotifier {
return _cachedFavoriteTracks;
}
/// Get favorite playlists from the library
Future<List<Playlist>> getFavoritePlaylists() async {
if (_api == null) return [];
try {
return await getPlaylists(favoriteOnly: true);
} catch (e) {
_logger.log('❌ Failed to fetch favorite playlists: $e');
return [];
}
}
/// Get favorite radio stations from the library
Future<List<MediaItem>> getFavoriteRadioStations() async {
if (_api == null) return [];
try {
final stations = await _api!.getRadioStations(favoriteOnly: true);
return filterByProvider(stations);
} catch (e) {
_logger.log('❌ Failed to fetch favorite radio stations: $e');
return [];
}
}
/// Get favorite podcasts from the library
Future<List<MediaItem>> getFavoritePodcasts() async {
if (_api == null) return [];
try {
final podcasts = await _api!.getPodcasts(favoriteOnly: true);
return filterByProvider(podcasts);
} catch (e) {
_logger.log('❌ Failed to fetch favorite podcasts: $e');
return [];
}
}
// ============================================================================
// AUDIOBOOK HOME SCREEN ROWS
// ============================================================================
/// Get audiobooks that have progress (continue listening)
Future<List<Audiobook>> getInProgressAudiobooks() async {
if (_api == null) return [];
/// Get audiobooks that have progress (continue listening) with caching
Future<List<Audiobook>> getInProgressAudiobooksWithCache({bool forceRefresh = false}) async {
// Check cache first
if (!forceRefresh && _cacheService.isInProgressAudiobooksCacheValid()) {
_logger.log('📦 Using cached in-progress audiobooks');
return _cacheService.getCachedInProgressAudiobooks()!;
}
if (_api == null) {
// Fallback to cache when offline
final cached = _cacheService.getCachedInProgressAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
try {
_logger.log('📚 Fetching in-progress audiobooks...');
@@ -2794,48 +3191,94 @@ class MusicAssistantProvider with ChangeNotifier {
.where((a) => a.progress > 0 && a.progress < 1.0) // Has progress but not complete
.toList()
..sort((a, b) => b.progress.compareTo(a.progress)); // Sort by progress descending
_logger.log('📚 Found ${inProgress.length} in-progress audiobooks');
return inProgress.take(20).toList(); // Limit to 20 for home row
final result = inProgress.take(20).toList(); // Limit to 20 for home row
_logger.log('📚 Found ${result.length} in-progress audiobooks');
_cacheService.setCachedInProgressAudiobooks(result);
return result;
} catch (e) {
_logger.log('❌ Failed to fetch in-progress audiobooks: $e');
// Fallback to cache on error
final cached = _cacheService.getCachedInProgressAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
}
/// Get random audiobooks for discovery
Future<List<Audiobook>> getDiscoverAudiobooks() async {
if (_api == null) return [];
/// Get cached in-progress audiobooks synchronously (for instant display)
List<Audiobook>? getCachedInProgressAudiobooks() => _cacheService.getCachedInProgressAudiobooks();
/// Get random audiobooks for discovery with caching
Future<List<Audiobook>> getDiscoverAudiobooksWithCache({bool forceRefresh = false}) async {
// Check cache first
if (!forceRefresh && _cacheService.isDiscoverAudiobooksCacheValid()) {
_logger.log('📦 Using cached discover audiobooks');
return _cacheService.getCachedDiscoverAudiobooks()!;
}
if (_api == null) {
// Fallback to cache when offline
final cached = _cacheService.getCachedDiscoverAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
try {
_logger.log('📚 Fetching discover audiobooks...');
final allAudiobooks = filterByProvider(await _api!.getAudiobooks());
// Shuffle and take a subset
final shuffled = List<Audiobook>.from(allAudiobooks)..shuffle();
final result = shuffled.take(20).toList();
_logger.log('📚 Found ${allAudiobooks.length} total audiobooks, returning random selection');
return shuffled.take(20).toList();
_cacheService.setCachedDiscoverAudiobooks(result);
return result;
} catch (e) {
_logger.log('❌ Failed to fetch discover audiobooks: $e');
// Fallback to cache on error
final cached = _cacheService.getCachedDiscoverAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
}
/// Get random series for discovery
Future<List<AudiobookSeries>> getDiscoverSeries() async {
if (_api == null) return [];
/// Get cached discover audiobooks synchronously (for instant display)
List<Audiobook>? getCachedDiscoverAudiobooks() => _cacheService.getCachedDiscoverAudiobooks();
/// Get random series for discovery with caching
Future<List<AudiobookSeries>> getDiscoverSeriesWithCache({bool forceRefresh = false}) async {
// Check cache first
if (!forceRefresh && _cacheService.isDiscoverSeriesCacheValid()) {
_logger.log('📦 Using cached discover series');
return _cacheService.getCachedDiscoverSeries()!;
}
if (_api == null) {
// Fallback to cache when offline
final cached = _cacheService.getCachedDiscoverSeries();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
try {
_logger.log('📚 Fetching discover series...');
final allSeries = await _api!.getAudiobookSeries();
// Shuffle and take a subset
final shuffled = List<AudiobookSeries>.from(allSeries)..shuffle();
final result = shuffled.take(20).toList();
_logger.log('📚 Found ${allSeries.length} total series, returning random selection');
return shuffled.take(20).toList();
_cacheService.setCachedDiscoverSeries(result);
return result;
} catch (e) {
_logger.log('❌ Failed to fetch discover series: $e');
// Fallback to cache on error
final cached = _cacheService.getCachedDiscoverSeries();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
}
/// Get cached discover series synchronously (for instant display)
List<AudiobookSeries>? getCachedDiscoverSeries() => _cacheService.getCachedDiscoverSeries();
// ============================================================================
// DETAIL SCREEN CACHING
// ============================================================================
@@ -2953,12 +3396,11 @@ class MusicAssistantProvider with ChangeNotifier {
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)');
return filterSearchResults(_cacheService.getCachedSearchResults(cacheKey)!);
return _cacheService.getCachedSearchResults(cacheKey)!;
}
if (_api == null) {
final cached = _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return filterSearchResults(cached);
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
}
try {
@@ -2975,11 +3417,10 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
_logger.log('✅ Cached search results for "$query"');
return filterSearchResults(cachedResults);
return cachedResults;
} catch (e) {
_logger.log('❌ Search failed: $e');
final cached = _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return filterSearchResults(cached);
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
}
}
@@ -3946,6 +4387,11 @@ class MusicAssistantProvider with ChangeNotifier {
_currentAudiobook = null;
stateChanged = true;
}
if (_currentPodcastName != null) {
_logger.log('🎙️ External source active, clearing podcast context');
_currentPodcastName = null;
stateChanged = true;
}
// Clear notification for external source
audioHandler.clearRemotePlaybackState();
if (stateChanged) {
@@ -3987,6 +4433,12 @@ class MusicAssistantProvider with ChangeNotifier {
_currentAudiobook = null;
stateChanged = true;
}
// Clear podcast context when playback stops
if (_currentPodcastName != null) {
_logger.log('🎙️ Playback stopped, clearing podcast context');
_currentPodcastName = null;
stateChanged = true;
}
if (stateChanged) {
notifyListeners();
@@ -4257,7 +4709,10 @@ class MusicAssistantProvider with ChangeNotifier {
// Fetch tracks from API (not cached - too many items)
if (_api != null) {
try {
_tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems);
_tracks = await _api!.getTracks(
limit: LibraryConstants.maxLibraryItems,
providerInstanceIds: providerIdsForApiCalls,
);
_logger.log('📥 Fetched ${_tracks.length} tracks from MA');
} catch (e) {
_logger.log('⚠️ Failed to fetch tracks: $e');
@@ -4297,18 +4752,18 @@ class MusicAssistantProvider with ChangeNotifier {
}
syncService.addListener(onSyncComplete);
await syncService.syncFromApi(_api!);
await syncService.syncFromApi(_api!, providerInstanceIds: providerIdsForApiCalls);
}
/// Load radio stations from the library
Future<void> loadRadioStations() async {
Future<void> loadRadioStations({String? orderBy}) async {
if (!isConnected || _api == null) return;
try {
_isLoadingRadio = true;
notifyListeners();
_radioStations = await _api!.getRadioStations(limit: 100);
_radioStations = await _api!.getRadioStations(limit: 100, orderBy: orderBy);
_isLoadingRadio = false;
notifyListeners();
} catch (e) {
@@ -4318,14 +4773,14 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<void> loadPodcasts() async {
Future<void> loadPodcasts({String? orderBy}) async {
if (!isConnected || _api == null) return;
try {
_isLoadingPodcasts = true;
notifyListeners();
_podcasts = await _api!.getPodcasts(limit: 100);
_podcasts = await _api!.getPodcasts(limit: 100, orderBy: orderBy);
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
_isLoadingPodcasts = false;
@@ -4368,7 +4823,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
Future<void> loadArtists({int? limit, int? offset, String? search, String? orderBy}) async {
if (!isConnected) return;
try {
@@ -4381,6 +4836,7 @@ class MusicAssistantProvider with ChangeNotifier {
offset: offset,
search: search,
albumArtistsOnly: false, // Show ALL library artists, not just those with albums
orderBy: orderBy,
);
_isLoading = false;
@@ -4398,6 +4854,7 @@ class MusicAssistantProvider with ChangeNotifier {
int? offset,
String? search,
String? artistId,
String? orderBy,
}) async {
if (!isConnected) return;
@@ -4411,6 +4868,7 @@ class MusicAssistantProvider with ChangeNotifier {
offset: offset,
search: search,
artistId: artistId,
orderBy: orderBy,
);
_isLoading = false;
@@ -4441,7 +4899,7 @@ class MusicAssistantProvider with ChangeNotifier {
try {
final results = await _api!.search(query, libraryOnly: libraryOnly);
return filterSearchResults(results);
return results;
} catch (e) {
ErrorHandler.logError('Search', e);
return {'artists': [], 'albums': [], 'tracks': []};
@@ -4449,10 +4907,10 @@ class MusicAssistantProvider with ChangeNotifier {
}
/// Get playlists with provider filtering applied
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly}) async {
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly, String? orderBy}) async {
if (_api == null) return [];
try {
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly);
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly, orderBy: orderBy);
return filterByProvider(playlists);
} catch (e) {
_logger.log('❌ Failed to fetch playlists: $e');
-964
View File
@@ -1,964 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/local_player_service.dart';
import '../services/auth/auth_manager.dart';
import '../services/device_id_service.dart';
import '../services/cache_service.dart';
import '../constants/timings.dart';
import '../main.dart' show audioHandler;
/// Provider for managing player state, selection, and controls
class PlayerProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
late final LocalPlayerService _localPlayer;
final CacheService _cacheService;
// Dependencies injected from parent provider
MusicAssistantAPI? _api;
AuthManager? _authManager;
String? _serverUrl;
// Player selection
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
Track? _currentTrack;
Timer? _playerStateTimer;
Timer? _adjacentPlayerCacheTimer;
// Local Playback
bool _isLocalPlayerPowered = true;
StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription;
Timer? _localPlayerStateReportTimer;
// Pending metadata from player_updated events
TrackMetadata? _pendingTrackMetadata;
TrackMetadata? _currentNotificationMetadata;
// Registration guard
Completer<void>? _registrationInProgress;
// Getters
Player? get selectedPlayer => _selectedPlayer;
List<Player> get availablePlayers => _availablePlayers;
Track? get currentTrack => _currentTrack;
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
/// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
PlayerProvider(this._cacheService, AuthManager authManager) {
_authManager = authManager;
_localPlayer = LocalPlayerService(authManager);
}
/// Initialize with API reference after connection
void initialize(MusicAssistantAPI api, String serverUrl) {
_api = api;
_serverUrl = serverUrl;
}
/// Set up event listeners after connection
Future<void> setupEventListeners() async {
if (_api == null) return;
// Listen to built-in player events
_localPlayerEventSubscription?.cancel();
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
_handleLocalPlayerEvent,
onError: (error) {
_logger.log('Builtin player event stream error: $error');
},
);
// Listen to player_updated events
_playerUpdatedEventSubscription?.cancel();
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
_handlePlayerUpdatedEvent,
onError: (error) {
_logger.log('Player updated event stream error: $error');
},
);
}
/// Initialize local playback
Future<void> initializeLocalPlayback() async {
await _localPlayer.initialize();
_isLocalPlayerPowered = true;
// Wire up skip button callbacks
audioHandler.onSkipToNext = () {
_logger.log('🎵 Notification: Skip to next pressed');
nextTrackSelectedPlayer();
};
audioHandler.onSkipToPrevious = () {
_logger.log('🎵 Notification: Skip to previous pressed');
previousTrackSelectedPlayer();
};
if (_api != null) {
await registerLocalPlayer();
}
}
/// Try to adopt an existing ghost player
Future<bool> tryAdoptGhostPlayer() async {
if (_api == null) return false;
try {
final isFresh = await DeviceIdService.isFreshInstallation();
if (!isFresh) {
_logger.log('👻 Not a fresh install, skipping ghost adoption');
return false;
}
final ownerName = await SettingsService.getOwnerName();
if (ownerName == null || ownerName.isEmpty) {
_logger.log('👻 No owner name set, cannot adopt ghost player');
return false;
}
_logger.log('👻 Fresh install detected, searching for adoptable ghost for "$ownerName"...');
final adoptableId = await _api!.findAdoptableGhostPlayer(ownerName);
if (adoptableId == null) {
_logger.log('👻 No matching ghost player found - will generate new ID');
return false;
}
_logger.log('👻 Found adoptable ghost: $adoptableId');
await DeviceIdService.adoptPlayerId(adoptableId);
_logger.log('✅ Successfully adopted ghost player');
return true;
} catch (e) {
_logger.log('⚠️ Ghost adoption failed (non-fatal): $e');
return false;
}
}
/// Register local player with MA server
Future<void> registerLocalPlayer() async {
if (_api == null) return;
if (_registrationInProgress != null) {
_logger.log('⏳ Registration already in progress, waiting...');
return _registrationInProgress!.future;
}
_registrationInProgress = Completer<void>();
try {
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('🆔 Using player ID: $playerId');
await SettingsService.setBuiltinPlayerId(playerId);
final name = await SettingsService.getLocalPlayerName();
final existingPlayers = await _api!.getPlayers();
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
if (existingPlayer != null && existingPlayer.available) {
_logger.log('✅ Player already registered and available: $playerId');
_startReportingLocalPlayerState();
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
return;
} else if (existingPlayer != null && !existingPlayer.available) {
_logger.log('⚠️ Player exists but unavailable (stale), re-registering: $playerId');
} else {
_logger.log('🆔 Player not found in MA, registering as new');
}
_logger.log('🎵 Registering player with MA: id=$playerId, name=$name');
await _api!.registerBuiltinPlayer(playerId, name);
_logger.log('✅ Player registration complete');
_startReportingLocalPlayerState();
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
} catch (e) {
_logger.log('❌ CRITICAL: Player registration failed: $e');
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.completeError(e);
}
_registrationInProgress = null;
rethrow;
}
}
void _startReportingLocalPlayerState() {
_localPlayerStateReportTimer?.cancel();
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
try {
await _reportLocalPlayerState();
} catch (e) {
_logger.log('Error reporting local player state (will retry): $e');
}
});
}
Future<void> _reportLocalPlayerState() async {
if (_api == null) return;
// Don't try to report state if not authenticated - avoids spamming errors
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) return;
final isPlaying = _localPlayer.isPlaying;
final position = _localPlayer.position.inSeconds;
final volume = (_localPlayer.volume * 100).round();
final isPaused = !isPlaying && position > 0;
await _api!.updateBuiltinPlayerState(
playerId,
powered: _isLocalPlayerPowered,
playing: isPlaying,
paused: isPaused,
position: position,
volume: volume,
muted: _localPlayer.volume == 0.0,
);
}
Future<void> _handleLocalPlayerEvent(Map<String, dynamic> event) async {
_logger.log('📥 Local player event received: ${event['type'] ?? event['command']}');
try {
final eventPlayerId = event['player_id'] as String?;
final myPlayerId = await SettingsService.getBuiltinPlayerId();
if (eventPlayerId != null && myPlayerId != null && eventPlayerId != myPlayerId) {
_logger.log('🚫 Ignoring event for different player: $eventPlayerId');
return;
}
final command = (event['type'] as String?) ?? (event['command'] as String?);
switch (command) {
case 'play_media':
final urlPath = event['media_url'] as String? ?? event['url'] as String?;
if (urlPath != null && _serverUrl != null) {
String fullUrl;
if (urlPath.startsWith('http')) {
fullUrl = urlPath;
} else {
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final path = urlPath.startsWith('/') ? urlPath : '/$urlPath';
fullUrl = '$baseUrl$path';
}
TrackMetadata metadata;
if (_pendingTrackMetadata != null) {
metadata = _pendingTrackMetadata!;
} else {
final trackName = event['track_name'] as String? ?? event['name'] as String? ?? 'Unknown Track';
final artistName = event['artist_name'] as String? ?? event['artist'] as String? ?? 'Unknown Artist';
final albumName = event['album_name'] as String? ?? event['album'] as String?;
var artworkUrl = event['image_url'] as String? ?? event['artwork_url'] as String?;
final durationSecs = event['duration'] as int?;
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
}
metadata = TrackMetadata(
title: trackName,
artist: artistName,
album: albumName,
artworkUrl: artworkUrl,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
}
_localPlayer.setCurrentTrackMetadata(metadata);
_currentNotificationMetadata = metadata;
await _localPlayer.playUrl(fullUrl);
}
break;
case 'stop':
await _localPlayer.stop();
break;
case 'pause':
await _localPlayer.pause();
break;
case 'play':
await _localPlayer.play();
break;
case 'seek':
final position = event['position'] as int?;
if (position != null) {
await _localPlayer.seek(Duration(seconds: position));
}
break;
case 'volume_set':
final volume = event['volume_level'] as int?;
if (volume != null) {
await _localPlayer.setVolume(volume / 100.0);
}
break;
case 'power_on':
case 'power_off':
case 'power':
bool? newPowerState;
if (command == 'power_on') {
newPowerState = true;
} else if (command == 'power_off') {
newPowerState = false;
} else {
newPowerState = event['powered'] as bool?;
}
if (newPowerState != null) {
_isLocalPlayerPowered = newPowerState;
if (!_isLocalPlayerPowered) {
await _localPlayer.stop();
}
}
break;
}
await _reportLocalPlayerState();
} catch (e) {
_logger.log('Error handling local player event: $e');
}
}
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
try {
final playerId = event['player_id'] as String?;
if (playerId == null) return;
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
_updatePlayerState();
}
final currentMedia = event['current_media'] as Map<String, dynamic>?;
final playerName = event['name'] as String? ?? playerId;
if (currentMedia != null) {
final mediaType = currentMedia['media_type'] as String?;
if (mediaType != 'flow_stream') {
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
final albumName = currentMedia['album'] as String?;
final imageUrl = currentMedia['image_url'] as String?;
Map<String, dynamic>? metadata;
if (imageUrl != null && _serverUrl != null) {
var finalImageUrl = imageUrl;
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
finalImageUrl = '$baseUrl/imageproxy?$queryString';
} catch (e) {
// Use original URL
}
metadata = {
'images': [{'path': finalImageUrl, 'provider': 'direct'}]
};
}
final trackFromEvent = Track(
itemId: currentMedia['queue_item_id'] as String? ?? '',
provider: 'library',
name: currentMedia['title'] as String? ?? 'Unknown Track',
uri: currentMedia['uri'] as String?,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
metadata: metadata,
);
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
_currentTrack = trackFromEvent;
}
_logger.log('📋 Cached track for $playerName: ${trackFromEvent.name}');
if (imageUrl != null) {
final images = metadata?['images'] as List?;
if (images != null && images.isNotEmpty) {
final imagePath = images[0]['path'] as String?;
if (imagePath != null) {
_precacheImage(imagePath);
}
}
}
notifyListeners();
}
}
// Handle notification metadata for local player
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId == null || playerId != builtinPlayerId) return;
if (currentMedia == null) return;
final title = currentMedia['title'] as String? ?? 'Unknown Track';
final artist = currentMedia['artist'] as String? ?? 'Unknown Artist';
final album = currentMedia['album'] as String?;
var imageUrl = currentMedia['image_url'] as String?;
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
if (imageUrl != null && _serverUrl != null) {
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
var baseUrl = _serverUrl!;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
imageUrl = '$baseUrl/imageproxy?$queryString';
} catch (e) {
if (imageUrl != null && imageUrl.startsWith('http://')) {
imageUrl = imageUrl.replaceFirst('http://', 'https://');
}
}
}
final newMetadata = TrackMetadata(
title: title,
artist: artist,
album: album,
artworkUrl: imageUrl,
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
);
final mediaType = currentMedia['media_type'] as String?;
if (mediaType == 'flow_stream') return;
_pendingTrackMetadata = newMetadata;
final notificationNeedsUpdate = _currentNotificationMetadata != null &&
(_currentNotificationMetadata!.title != title ||
_currentNotificationMetadata!.artist != artist);
if (_localPlayer.isPlaying && notificationNeedsUpdate) {
await _localPlayer.updateNotificationWhilePlaying(newMetadata);
_currentNotificationMetadata = newMetadata;
}
} catch (e) {
_logger.log('Error handling player_updated event: $e');
}
}
// ============================================================================
// PLAYER SELECTION
// ============================================================================
Future<void> loadAndSelectPlayers({bool forceRefresh = false}) async {
if (_api == null) return;
try {
if (!forceRefresh && _cacheService.isPlayersCacheValid() && _availablePlayers.isNotEmpty) {
return;
}
final allPlayers = await _api!.getPlayers();
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
int filteredCount = 0;
_availablePlayers = allPlayers.where((player) {
final nameLower = player.name.toLowerCase();
if (nameLower.contains('music assistant mobile')) {
filteredCount++;
return false;
}
// Filter out MA Web UI's built-in player (provider is 'builtin_player' and starts with 'ma_')
// Note: We check BOTH conditions to avoid filtering snapcast/other players that may have 'ma_' prefix
if (player.provider == 'builtin_player' && player.playerId.startsWith('ma_')) {
filteredCount++;
return false;
}
// Also filter "This Device" named players without proper provider
if (nameLower == 'this device') {
filteredCount++;
return false;
}
if (player.playerId.startsWith('ensemble_')) {
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
filteredCount++;
return false;
}
}
if (!player.available) {
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
return true;
}
filteredCount++;
return false;
}
return true;
}).toList();
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
_cacheService.updatePlayersLastFetched();
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
if (_availablePlayers.isNotEmpty) {
Player? playerToSelect;
if (_selectedPlayer != null) {
final stillAvailable = _availablePlayers.any(
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
);
if (stillAvailable) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
}
}
if (playerToSelect == null) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
// Priority 1: Last selected player
if (lastSelectedPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId && p.available,
orElse: () => null,
);
}
// Priority 2: Built-in (local) player
if (playerToSelect == null && builtinPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == builtinPlayerId && p.available,
orElse: () => null,
);
}
// Priority 3: Currently playing player (exclude external sources - not playing MA content)
if (playerToSelect == null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.state == 'playing' && p.available && !p.isExternalSource,
orElse: () => null,
);
}
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
);
}
}
selectPlayer(playerToSelect);
}
_preloadAdjacentPlayers(preloadAll: true);
} catch (e) {
ErrorHandler.logError('Load and select players', e);
}
}
void selectPlayer(Player player, {bool skipNotify = false}) {
_selectedPlayer = player;
SettingsService.setLastSelectedPlayerId(player.playerId);
_startPlayerStatePolling();
_preloadAdjacentPlayers();
if (!skipNotify) {
notifyListeners();
}
}
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
if (_api == null) return;
final players = _availablePlayers.where((p) => p.available).toList();
if (players.isEmpty) return;
if (preloadAll) {
_logger.log('🖼️ Preloading track info for all ${players.length} players...');
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
_logger.log('🖼️ Preloading complete');
return;
}
if (_selectedPlayer == null || players.length <= 1) return;
final currentIndex = players.indexWhere((p) => p.playerId == _selectedPlayer!.playerId);
if (currentIndex == -1) return;
final prevIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
final nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
final playersToPreload = <Player>{};
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
for (final player in playersToPreload) {
_preloadPlayerTrack(player);
}
}
Future<void> _preloadPlayerTrack(Player player) async {
if (_api == null) return;
try {
if (!player.available || !player.powered) {
_cacheService.setCachedTrackForPlayer(player.playerId, null);
return;
}
final queue = await _api!.getQueue(player.playerId);
if (queue != null && queue.currentItem != null) {
final track = queue.currentItem!.track;
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
final existingHasImage = existingTrack?.metadata?['images'] != null;
final newHasImage = track.metadata?['images'] != null;
if (!existingHasImage || newHasImage) {
_cacheService.setCachedTrackForPlayer(player.playerId, track);
}
final artworkUrl512 = _api!.getImageUrl(existingHasImage ? existingTrack! : track, size: 512);
if (artworkUrl512 != null) {
await _precacheImage(artworkUrl512);
}
} else {
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
if (existingTrack?.metadata?['images'] == null) {
_cacheService.setCachedTrackForPlayer(player.playerId, null);
}
}
} catch (e) {
_logger.log('Error preloading player track for ${player.name}: $e');
}
}
Future<void> _precacheImage(String url) async {
try {
final imageProvider = NetworkImage(url);
final imageStream = imageProvider.resolve(const ImageConfiguration());
final completer = Completer<void>();
late ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool synchronousCall) {
if (!completer.isCompleted) completer.complete();
imageStream.removeListener(listener);
},
onError: (exception, stackTrace) {
if (!completer.isCompleted) completer.completeError(exception);
imageStream.removeListener(listener);
},
);
imageStream.addListener(listener);
await completer.future.timeout(const Duration(seconds: 10), onTimeout: () {
imageStream.removeListener(listener);
});
} catch (e) {
_logger.log('Image precache failed for $url: $e');
}
}
Future<void> preloadAllPlayerTracks() async {
if (_api == null) return;
final players = _availablePlayers.where((p) => p.available).toList();
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
}
void _startPlayerStatePolling() {
_playerStateTimer?.cancel();
if (_selectedPlayer == null) return;
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
try {
await _updatePlayerState();
} catch (e) {
_logger.log('Error updating player state (will retry): $e');
}
});
_updatePlayerState();
}
Future<void> _updatePlayerState() async {
if (_selectedPlayer == null || _api == null) return;
try {
bool stateChanged = false;
final allPlayers = await _api!.getPlayers();
final updatedPlayer = allPlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
orElse: () => _selectedPlayer!,
);
_selectedPlayer = updatedPlayer;
stateChanged = true;
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
if (!shouldShowTrack) {
if (_currentTrack != null) {
_currentTrack = null;
stateChanged = true;
}
if (stateChanged) notifyListeners();
return;
}
final queue = await _api!.getQueue(_selectedPlayer!.playerId);
if (queue != null && queue.currentItem != null) {
if (_currentTrack == null ||
_currentTrack!.uri != queue.currentItem!.track.uri ||
_currentTrack!.name != queue.currentItem!.track.name) {
_currentTrack = queue.currentItem!.track;
stateChanged = true;
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId) {
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
_localPlayer.updateNotification(
id: track.uri ?? track.itemId,
title: track.name,
artist: track.artistsString,
album: track.album?.name,
artworkUrl: artworkUrl,
duration: track.duration,
);
}
}
} else {
if (_currentTrack != null) {
_currentTrack = null;
stateChanged = true;
}
}
if (stateChanged) notifyListeners();
} catch (e) {
_logger.log('❌ Error updating player state: $e');
}
}
// ============================================================================
// PLAYER CONTROLS
// ============================================================================
Future<void> playPauseSelectedPlayer() async {
if (_selectedPlayer == null || _api == null) return;
if (_selectedPlayer!.isPlaying) {
await _api!.pausePlayer(_selectedPlayer!.playerId);
} else {
await _api!.resumePlayer(_selectedPlayer!.playerId);
}
await refreshPlayers();
}
Future<void> nextTrackSelectedPlayer() async {
if (_selectedPlayer == null || _api == null) return;
await _api!.nextTrack(_selectedPlayer!.playerId);
await Future.delayed(Timings.trackChangeDelay);
await _updatePlayerState();
}
Future<void> previousTrackSelectedPlayer() async {
if (_selectedPlayer == null || _api == null) return;
await _api!.previousTrack(_selectedPlayer!.playerId);
await Future.delayed(Timings.trackChangeDelay);
await _updatePlayerState();
}
Future<void> refreshPlayers() async {
final previousState = _selectedPlayer?.state;
final previousVolume = _selectedPlayer?.volumeLevel;
await loadAndSelectPlayers(forceRefresh: true);
bool stateChanged = false;
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
try {
final updatedPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == _selectedPlayer!.playerId,
);
if (updatedPlayer.state != previousState || updatedPlayer.volumeLevel != previousVolume) {
stateChanged = true;
}
_selectedPlayer = updatedPlayer;
} catch (e) {
stateChanged = true;
}
}
if (stateChanged) notifyListeners();
}
Future<void> togglePower(String playerId) async {
if (_api == null) return;
try {
final localPlayerId = await SettingsService.getBuiltinPlayerId();
final isLocalPlayer = localPlayerId != null && playerId == localPlayerId;
if (isLocalPlayer) {
_isLocalPlayerPowered = !_isLocalPlayerPowered;
if (!_isLocalPlayerPowered) {
await _localPlayer.stop();
}
await _reportLocalPlayerState();
await refreshPlayers();
} else {
final player = _availablePlayers.firstWhere(
(p) => p.playerId == playerId,
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
? _selectedPlayer!
: throw Exception("Player not found"),
);
await _api!.setPower(playerId, !player.powered);
await refreshPlayers();
}
} catch (e) {
ErrorHandler.logError('Toggle power', e);
}
}
Future<void> setVolume(String playerId, int volumeLevel) async {
if (_api == null) return;
try {
await _api!.setVolume(playerId, volumeLevel);
} catch (e) {
ErrorHandler.logError('Set volume', e);
rethrow;
}
}
Future<void> setMute(String playerId, bool muted) async {
if (_api == null) return;
try {
await _api!.setMute(playerId, muted);
await refreshPlayers();
} catch (e) {
ErrorHandler.logError('Set mute', e);
rethrow;
}
}
Future<void> seek(String playerId, int position) async {
if (_api == null) return;
try {
await _api!.seek(playerId, position);
} catch (e) {
ErrorHandler.logError('Seek', e);
rethrow;
}
}
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
if (_api == null) return;
try {
await _api!.toggleShuffle(queueId, shuffleEnabled);
} catch (e) {
ErrorHandler.logError('Toggle shuffle', e);
rethrow;
}
}
Future<void> setRepeatMode(String queueId, String mode) async {
if (_api == null) return;
try {
await _api!.setRepeatMode(queueId, mode);
} catch (e) {
ErrorHandler.logError('Set repeat mode', e);
rethrow;
}
}
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
String nextMode;
switch (currentMode) {
case 'off':
case null:
nextMode = 'all';
break;
case 'all':
nextMode = 'one';
break;
case 'one':
nextMode = 'off';
break;
default:
nextMode = 'off';
}
await setRepeatMode(queueId, nextMode);
}
void clearState() {
_playerStateTimer?.cancel();
_playerStateTimer = null;
_localPlayerStateReportTimer?.cancel();
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
_availablePlayers = [];
_selectedPlayer = null;
_currentTrack = null;
}
@override
void dispose() {
clearState();
super.dispose();
}
}
+55 -26
View File
@@ -12,6 +12,7 @@ import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'artist_details_screen.dart';
class AlbumDetailsScreen extends StatefulWidget {
@@ -222,7 +223,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
@@ -253,12 +253,35 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
// Fire and forget - API call happens in background
maProvider.addToLibrary(
mediaType: 'album',
provider: actualProvider,
itemId: actualItemId,
);
).catchError((e) {
_logger.log('❌ Failed to add album to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
} else {
// Remove from library
int? libraryItemId;
@@ -279,13 +302,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
);
}
if (success) {
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
@@ -293,13 +310,25 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
// Fire and forget - API call happens in background
maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
).catchError((e) {
_logger.log('❌ Failed to remove album from library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
}
} catch (e) {
_logger.log('Error toggling album library: $e');
@@ -529,12 +558,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.addAlbumToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -609,12 +638,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -672,8 +701,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _navigateToArtist() {
// Navigate to the first artist if available
if (widget.album.artists != null && widget.album.artists!.isNotEmpty) {
final artist = widget.album.artists!.first;
if (_displayAlbum.artists != null && _displayAlbum.artists!.isNotEmpty) {
final artist = _displayAlbum.artists!.first;
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(artist, size: 256);
Navigator.push(
@@ -902,7 +931,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
Hero(
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
child: Material(
@@ -913,7 +942,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(
widget.album.artistsString,
_displayAlbum.artistsString,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
@@ -922,7 +951,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(height: 16),
Spacing.vGap16,
// Album Description
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
InkWell(
@@ -944,7 +973,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
],
Row(
children: [
@@ -1015,7 +1044,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25), // Circular
borderRadius: BorderRadius.circular(Radii.xxl), // Circular
),
),
child: Icon(
@@ -1193,7 +1222,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1216,7 +1245,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
childCount: _tracks.length,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
);
},
+56 -27
View File
@@ -13,6 +13,7 @@ import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class ArtistDetailsScreen extends StatefulWidget {
final Artist artist;
@@ -307,7 +308,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
@@ -338,12 +338,35 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
// Fire and forget - API call happens in background
maProvider.addToLibrary(
mediaType: 'artist',
provider: actualProvider,
itemId: actualItemId,
);
).catchError((e) {
_logger.log('❌ Failed to add artist to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
} else {
// Remove from library
int? libraryItemId;
@@ -364,13 +387,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
if (success) {
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
@@ -378,13 +395,25 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
// Fire and forget - API call happens in background
maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
).catchError((e) {
_logger.log('❌ Failed to remove artist from library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
}
} catch (e) {
_logger.log('Error toggling artist library: $e');
@@ -417,12 +446,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.startRadioOn(widget.artist.name),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -497,12 +526,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -786,7 +815,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(height: 16),
Spacing.vGap16,
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
InkWell(
onTap: () {
@@ -807,7 +836,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
],
// Action Buttons Row
Row(
@@ -861,7 +890,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -897,7 +926,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
],
),
const SizedBox(height: 16),
Spacing.vGap16,
],
),
),
@@ -947,7 +976,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
// View mode toggle
IconButton(
@@ -968,7 +997,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
),
@@ -1004,7 +1033,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
IconButton(
icon: Icon(
@@ -1024,7 +1053,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
],
@@ -1032,7 +1061,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
_buildAlbumSliver(_providerAlbums),
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
],
],
@@ -1104,7 +1133,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
Hero(
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + '_$heroSuffix',
child: Material(
+40 -28
View File
@@ -150,10 +150,16 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
@@ -172,37 +178,41 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
Hero(
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
child: Container(
width: 200,
height: 200,
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: ClipOval(
child: _authorImageUrl != null
? CachedNetworkImage(
imageUrl: _authorImageUrl!,
fit: BoxFit.cover,
width: 200,
height: 200,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
child: Stack(
alignment: Alignment.center,
children: [
// Icon always present underneath
Icon(
MdiIcons.accountOutline,
size: coverSize * 0.5,
color: colorScheme.onPrimaryContainer,
),
// Image covers icon when loaded
if (_authorImageUrl != null)
SizedBox(
width: coverSize,
height: coverSize,
child: ClipOval(
child: CachedNetworkImage(
imageUrl: _authorImageUrl!,
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
memCacheWidth: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
),
)
: Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
),
),
],
),
),
),
@@ -287,8 +297,10 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
),
_buildAudiobookSliver(),
const SliverToBoxAdapter(child: SizedBox(height: 140)),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
);
},
),
),
);
+7 -6
View File
@@ -12,6 +12,7 @@ import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class AudiobookDetailScreen extends StatefulWidget {
final Audiobook audiobook;
@@ -566,7 +567,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
// Author
Text(
@@ -588,7 +589,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
],
// Duration & Progress
const SizedBox(height: 16),
Spacing.vGap16,
Row(
children: [
if (book.duration != null) ...[
@@ -712,7 +713,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -736,7 +737,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Spacing.vGap8,
InkWell(
onTap: () {
setState(() {
@@ -767,7 +768,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Spacing.vGap8,
],
),
),
@@ -777,7 +778,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Use MA chapters if available, otherwise use ABS chapters
..._buildChapterSection(book, colorScheme, textTheme),
const SliverToBoxAdapter(child: SizedBox(height: 140)),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
);
},
+6
View File
@@ -68,6 +68,12 @@ class _HomeScreenState extends State<HomeScreen> {
return;
}
// If queue panel is open, close it first (before collapsing player)
if (GlobalPlayerOverlay.isQueuePanelOpen) {
GlobalPlayerOverlay.closeQueuePanel();
return;
}
// If global player is expanded, collapse it first
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
+52 -26
View File
@@ -4,8 +4,12 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../widgets/common/empty_state.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import 'playlist_details_screen.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class LibraryPlaylistsScreen extends StatefulWidget {
const LibraryPlaylistsScreen({super.key});
@@ -104,34 +108,55 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Unique suffix for this context to avoid hero tag conflicts
const heroSuffix = '_playlists';
return RepaintBoundary(
child: ListTile(
key: ValueKey(playlist.itemId),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
leading: Hero(
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
),
),
child: imageUrl == null
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
playlist.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
title: Hero(
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: Material(
color: Colors.transparent,
child: Text(
playlist.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
playlist.trackCount != null
@@ -144,16 +169,17 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
overflow: TextOverflow.ellipsis,
),
trailing: playlist.favorite == true
? const Icon(Icons.favorite, color: Colors.red, size: 20)
? const Icon(Icons.favorite, color: StatusColors.favorite, size: 20)
: null,
onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistDetailsScreen(
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
provider: playlist.provider,
itemId: playlist.itemId,
heroTagSuffix: 'playlists',
initialImageUrl: imageUrl,
),
),
);
+157 -6
View File
@@ -13,6 +13,9 @@ import '../widgets/artist_row.dart';
import '../widgets/track_row.dart';
import '../widgets/audiobook_row.dart';
import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart';
import '../widgets/podcast_row.dart';
import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -35,6 +38,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
bool _showFavoritePodcasts = false;
// Audiobook rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
@@ -45,19 +51,89 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
@override
bool get wantKeepAlive => true;
// Track if we had empty favorites on first load (to know when to refresh)
bool _hadEmptyFavoritesOnLoad = false;
SyncStatus? _lastSyncStatus;
int _lastArtistCount = 0;
MusicAssistantProvider? _providerListeningTo;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadSettings();
// Listen to sync completion to refresh favorite rows when data becomes available
SyncService.instance.addListener(_onSyncChanged);
_lastSyncStatus = SyncService.instance.status;
_checkInitialFavoriteState();
}
@override
void dispose() {
SyncService.instance.removeListener(_onSyncChanged);
// Clean up provider listener if attached
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
/// Check if favorites are empty on initial load (so we know to refresh after sync)
void _checkInitialFavoriteState() {
// Schedule after first frame to ensure provider is accessible
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
_lastArtistCount = provider.artists.length;
// Check if artists list is empty - favorites will also be empty
if (_lastArtistCount == 0) {
_hadEmptyFavoritesOnLoad = true;
_logger.log('📋 Home: favorites empty on load, will refresh after sync');
// Also listen to provider for when cache loads (before sync completes)
_providerListeningTo = provider;
provider.addListener(_onProviderChanged);
}
});
}
/// Called when MusicAssistantProvider changes (for cache load detection)
void _onProviderChanged() {
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
final currentCount = _providerListeningTo!.artists.length;
// If artists went from 0 to non-zero, refresh
if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false;
_providerListeningTo!.removeListener(_onProviderChanged);
_providerListeningTo = null;
setState(() {
_refreshKey = UniqueKey();
});
}
_lastArtistCount = currentCount;
}
/// Called when SyncService status changes
void _onSyncChanged() {
final newStatus = SyncService.instance.status;
// When sync completes and we had empty favorites, refresh to show new data
if (_lastSyncStatus == SyncStatus.syncing &&
newStatus == SyncStatus.completed &&
_hadEmptyFavoritesOnLoad) {
_logger.log('🔄 Home: sync completed, refreshing favorite rows');
_hadEmptyFavoritesOnLoad = false;
// Clean up provider listener if still attached
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
if (mounted) {
setState(() {
_refreshKey = UniqueKey();
});
}
}
_lastSyncStatus = newStatus;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Reload settings when app resumes (coming back from settings)
@@ -73,6 +149,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
@@ -85,6 +164,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
@@ -238,15 +320,50 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
}
/// Count how many rows are currently enabled
int _countEnabledRows() {
int count = 0;
for (final rowId in _homeRowOrder) {
if (_isRowEnabled(rowId)) count++;
}
return count;
}
/// Check if a specific row is enabled
bool _isRowEnabled(String rowId) {
switch (rowId) {
case 'recent-albums': return _showRecentAlbums;
case 'discover-artists': return _showDiscoverArtists;
case 'discover-albums': return _showDiscoverAlbums;
case 'continue-listening': return _showContinueListeningAudiobooks;
case 'discover-audiobooks': return _showDiscoverAudiobooks;
case 'discover-series': return _showDiscoverSeries;
case 'favorite-albums': return _showFavoriteAlbums;
case 'favorite-artists': return _showFavoriteArtists;
case 'favorite-tracks': return _showFavoriteTracks;
case 'favorite-playlists': return _showFavoritePlaylists;
case 'favorite-radio-stations': return _showFavoriteRadioStations;
case 'favorite-podcasts': return _showFavoritePodcasts;
default: return false;
}
}
Widget _buildConnectedView(
BuildContext context, MusicAssistantProvider provider) {
// Use LayoutBuilder to get available screen height
return LayoutBuilder(
builder: (context, constraints) {
// Simple calculation: available height divided by 3 rows
// Each row includes its title, artwork, and info
// Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
final rowHeight = (availableHeight + 8) / 3;
// Account for margins between rows (2px each)
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
// For 4+ rows, use original calculation so 4th row stays hidden (scrollable)
const marginSize = 2.0;
final enabledRows = _countEnabledRows();
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
final rowHeight = (availableHeight - marginsInView) / 3;
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
@@ -288,6 +405,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) {
// Add spacing between rows (not before first row)
if (rows.isNotEmpty) {
rows.add(const SizedBox(height: 2.0));
}
rows.add(widget);
}
}
@@ -330,7 +451,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
return AudiobookRow(
key: const ValueKey('continue-listening'),
title: S.of(context)!.continueListening,
loadAudiobooks: () => provider.getInProgressAudiobooks(),
loadAudiobooks: () => provider.getInProgressAudiobooksWithCache(),
getCachedAudiobooks: () => provider.getCachedInProgressAudiobooks(),
rowHeight: rowHeight,
);
case 'discover-audiobooks':
@@ -338,7 +460,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
return AudiobookRow(
key: const ValueKey('discover-audiobooks'),
title: S.of(context)!.discoverAudiobooks,
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
loadAudiobooks: () => provider.getDiscoverAudiobooksWithCache(),
getCachedAudiobooks: () => provider.getCachedDiscoverAudiobooks(),
rowHeight: rowHeight,
);
case 'discover-series':
@@ -346,7 +469,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
return SeriesRow(
key: const ValueKey('discover-series'),
title: S.of(context)!.discoverSeries,
loadSeries: () => provider.getDiscoverSeries(),
loadSeries: () => provider.getDiscoverSeriesWithCache(),
getCachedSeries: () => provider.getCachedDiscoverSeries(),
rowHeight: rowHeight,
);
case 'favorite-albums':
@@ -373,6 +497,33 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
loadTracks: () => provider.getFavoriteTracks(),
rowHeight: rowHeight,
);
case 'favorite-playlists':
if (!_showFavoritePlaylists) return null;
return PlaylistRow(
key: const ValueKey('favorite-playlists'),
title: S.of(context)!.favoritePlaylists,
loadPlaylists: () => provider.getFavoritePlaylists(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
case 'favorite-radio-stations':
if (!_showFavoriteRadioStations) return null;
return RadioStationRow(
key: const ValueKey('favorite-radio-stations'),
title: S.of(context)!.favoriteRadioStations,
loadRadioStations: () => provider.getFavoriteRadioStations(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
case 'favorite-podcasts':
if (!_showFavoritePodcasts) return null;
return PodcastRow(
key: const ValueKey('favorite-podcasts'),
title: S.of(context)!.favoritePodcasts,
loadPodcasts: () => provider.getFavoritePodcasts(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
default:
return null;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+79 -19
View File
@@ -66,7 +66,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
@@ -97,12 +96,35 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
}
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
// Fire and forget - API call happens in background
maProvider.addToLibrary(
mediaType: 'podcast',
provider: actualProvider,
itemId: actualItemId,
);
).catchError((e) {
_logger.log('❌ Failed to add podcast to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
} else {
// Remove from library
int? libraryItemId;
@@ -123,13 +145,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success) {
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
@@ -137,13 +153,25 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
// Fire and forget - API call happens in background
maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
).catchError((e) {
_logger.log('❌ Failed to remove podcast from library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
}
} catch (e) {
_logger.log('Error toggling podcast library: $e');
@@ -190,10 +218,38 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
try {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
List<MediaItem> episodes = [];
// Try loading with the podcast's own ID and provider first
try {
episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
} catch (e) {
_logger.log('🎙️ Primary episode load failed: $e');
}
// If that failed and we have provider mappings, try those
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
for (final mapping in widget.podcast.providerMappings!) {
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
try {
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
episodes = await maProvider.api!.getPodcastEpisodes(
mapping.itemId,
provider: mapping.providerInstance,
);
if (episodes.isNotEmpty) {
_logger.log('🎙️ Loaded episodes via provider mapping');
break;
}
} catch (e) {
_logger.log('🎙️ Provider mapping failed: $e');
}
}
}
}
if (mounted) {
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
@@ -225,6 +281,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (selectedPlayer != null) {
try {
// Set podcast context before playing so player UI shows correct podcast name
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -259,6 +317,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
// Set podcast context before playing so player UI shows correct podcast name
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
},
).whenComplete(() {
@@ -856,8 +916,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
// Bottom padding for mini player
const SliverToBoxAdapter(
child: SizedBox(height: 100),
SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
);
+68
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/player_picker_sheet.dart';
import '../l10n/app_localizations.dart';
class QueueScreen extends StatefulWidget {
@@ -67,6 +68,68 @@ class _QueueScreenState extends State<QueueScreen> {
}
}
Future<void> _handleTransferQueue(MusicAssistantProvider maProvider) async {
final player = maProvider.selectedPlayer;
if (player == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
// Get available players, excluding the current source player
final allPlayers = maProvider.availablePlayers;
final targetPlayers = allPlayers
.where((p) => p.playerId != player.playerId && p.available)
.toList();
if (targetPlayers.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.noOtherPlayersAvailable),
duration: const Duration(seconds: 2),
),
);
return;
}
// Show player picker sheet
await showPlayerPickerSheet(
context: context,
title: S.of(context)!.transferQueueTo,
players: targetPlayers,
onPlayerSelected: (targetPlayer) async {
try {
await maProvider.api?.transferQueue(
sourceQueueId: player.playerId,
targetQueueId: targetPlayer.playerId,
autoPlay: true,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.queueTransferredTo(targetPlayer.name)),
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
_logger.log('Error transferring queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToTransferQueue(e.toString())),
backgroundColor: Colors.red.shade700,
duration: const Duration(seconds: 3),
),
);
}
}
},
);
}
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
@@ -87,6 +150,11 @@ class _QueueScreenState extends State<QueueScreen> {
),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.swap_horiz_rounded),
tooltip: S.of(context)!.transferQueue,
onPressed: () => _handleTransferQueue(maProvider),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadQueue,
+352 -316
View File
@@ -15,12 +15,14 @@ import '../widgets/artist_avatar.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../utils/search/search_scoring.dart';
import 'album_details_screen.dart';
import 'artist_details_screen.dart';
import 'playlist_details_screen.dart';
import 'audiobook_detail_screen.dart';
import 'podcast_detail_screen.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@@ -128,11 +130,21 @@ class SearchScreenState extends State<SearchScreen> {
// PERF: Cache available filters to avoid recalculating during build
List<String>? _cachedAvailableFilters;
// Advanced search scorer with fuzzy matching, stopword removal, etc.
final SearchScorer _searchScorer = SearchScorer();
// Scroll-to-hide search bar (vertical scroll only)
bool _isSearchBarVisible = true;
double _lastVerticalScrollOffset = 0;
static const double _scrollThreshold = 10.0;
// Filter chip position tracking for animated sliding highlight
final Map<String, GlobalKey> _filterKeys = {};
final Map<String, double> _filterWidths = {};
final Map<String, double> _filterPositions = {};
double _highlightLeft = 0;
double _highlightWidth = 80; // Default width until measured
@override
void initState() {
super.initState();
@@ -157,6 +169,27 @@ class SearchScreenState extends State<SearchScreen> {
return filters;
}
/// Filter search results by enabled providers (only when in-library mode is on)
/// Uses the client-side provider toggles from the library screen
Map<String, List<MediaItem>> _filterByEnabledProviders(
Map<String, List<MediaItem>> results,
Set<String> enabledProviders,
) {
if (enabledProviders.isEmpty) return results; // No filter = show all
bool isItemAllowed(MediaItem item) {
final mappings = item.providerMappings;
if (mappings == null || mappings.isEmpty) return true; // No mappings = show
// Item is allowed if it has inLibrary=true for any enabled provider
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
}
return {
for (final entry in results.entries)
entry.key: entry.value.where(isItemAllowed).toList(),
};
}
/// Animate to a specific filter
void _animateToFilter(String filter) {
final filters = _getAvailableFilters();
@@ -181,6 +214,39 @@ class SearchScreenState extends State<SearchScreen> {
}
}
/// Measure filter chip positions and update highlight
void _measureFilterPositions() {
final filters = _getAvailableFilters();
double left = 0;
for (final filter in filters) {
final key = _filterKeys[filter];
if (key?.currentContext != null) {
final box = key!.currentContext!.findRenderObject() as RenderBox?;
if (box != null && box.hasSize) {
_filterWidths[filter] = box.size.width;
_filterPositions[filter] = left;
left += box.size.width;
}
}
}
}
/// Update highlight position for the active filter
void _updateHighlightPosition(String activeFilter) {
_measureFilterPositions();
final position = _filterPositions[activeFilter];
final width = _filterWidths[activeFilter];
if (position != null && width != null) {
setState(() {
_highlightLeft = position;
_highlightWidth = width;
});
}
}
/// Scroll filter bar to keep active filter visible
/// Only scrolls when the filter would be obscured (off-screen)
/// Uses post-frame callback to avoid interfering with PageView animation
@@ -196,30 +262,35 @@ class SearchScreenState extends State<SearchScreen> {
// If all filters fit on screen, don't scroll at all
if (maxScroll <= 0) return;
// Approximate width per filter chip (padding + text)
const chipWidth = 80.0;
const horizontalPadding = 16.0;
final filters = _getAvailableFilters();
if (filterIndex < 0 || filterIndex >= filters.length) return;
final filter = filters[filterIndex];
// Update highlight position
_updateHighlightPosition(filter);
// Use measured positions if available, fallback to estimate
final chipLeft = _filterPositions[filter] ?? (filterIndex * 80.0);
final chipWidth = _filterWidths[filter] ?? 80.0;
final chipRight = chipLeft + chipWidth;
final currentScroll = _filterScrollController.offset;
final viewportWidth = _filterScrollController.position.viewportDimension;
// Calculate the left and right edges of the active filter chip
final chipLeft = (filterIndex * chipWidth);
final chipRight = chipLeft + chipWidth;
// Calculate what's currently visible in the viewport
final visibleLeft = currentScroll;
final visibleRight = currentScroll + viewportWidth - (horizontalPadding * 2);
final visibleRight = currentScroll + viewportWidth;
// Only scroll if the chip is actually obscured
double? targetOffset;
if (chipRight > visibleRight) {
// Chip is cut off on the right - scroll right just enough to show it
targetOffset = chipRight - viewportWidth + (horizontalPadding * 2);
// Chip is cut off on the right - scroll right to show it with padding
targetOffset = chipRight - viewportWidth + 8;
} else if (chipLeft < visibleLeft) {
// Chip is cut off on the left - scroll left just enough to show it
targetOffset = chipLeft;
// Chip is cut off on the left - scroll left to show it with padding
targetOffset = chipLeft - 8;
}
// Only animate if we need to scroll
@@ -378,12 +449,21 @@ class SearchScreenState extends State<SearchScreen> {
}
if (mounted) {
// Combine all results
var combinedResults = {
...results,
'radios': allRadios.values.toList(),
'podcasts': allPodcasts.values.toList(),
};
// When "in library" mode is on, also filter by enabled providers
if (_libraryOnly) {
final enabledProviders = provider.enabledProviderIds.toSet();
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
}
setState(() {
_searchResults = {
...results,
'radios': allRadios.values.toList(),
'podcasts': allPodcasts.values.toList(),
};
_searchResults = combinedResults;
_isSearching = false;
_hasSearched = true;
_cachedListItems.clear(); // PERF: Clear cache on new results
@@ -437,81 +517,97 @@ class SearchScreenState extends State<SearchScreen> {
decoration: const BoxDecoration(),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
// Search field
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: TextField(
controller: _searchController,
focusNode: _focusNode,
style: TextStyle(color: colorScheme.onSurface),
cursorColor: colorScheme.primary,
textInputAction: TextInputAction.search,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
hintText: S.of(context)!.searchMusic,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
border: InputBorder.none,
suffixIcon: _hasSearchText
? IconButton(
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
onPressed: () {
_searchController.clear();
setState(() {
_hasSearchText = false;
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
_hasSearched = false;
_searchError = null;
_cachedListItems.clear();
_cachedAvailableFilters = null;
});
},
)
: null,
child: Row(
children: [
// Search field container
Expanded(
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: TextField(
controller: _searchController,
focusNode: _focusNode,
style: TextStyle(color: colorScheme.onSurface),
cursorColor: colorScheme.primary,
textInputAction: TextInputAction.search,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
hintText: S.of(context)!.searchMusic,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
border: InputBorder.none,
prefixIcon: Icon(
Icons.search_rounded,
color: colorScheme.onSurface.withOpacity(0.5),
),
onChanged: (value) {
// PERF: Only rebuild when clear button visibility changes
final hasText = value.isNotEmpty;
if (hasText != _hasSearchText) {
setState(() {
_hasSearchText = hasText;
});
}
_onSearchChanged(value);
},
onSubmitted: (query) => _performSearch(query),
suffixIcon: _hasSearchText
? IconButton(
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
onPressed: () {
_searchController.clear();
setState(() {
_hasSearchText = false;
_searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
_hasSearched = false;
_searchError = null;
_cachedListItems.clear();
_cachedAvailableFilters = null;
});
},
)
: null,
),
),
),
// Library-only toggle
Tooltip(
message: S.of(context)!.libraryOnly,
child: IconButton(
icon: Icon(
_libraryOnly ? Icons.library_music : Icons.library_music_outlined,
color: _libraryOnly ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.5),
),
onPressed: () {
setState(() {
_libraryOnly = !_libraryOnly;
});
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
onChanged: (value) {
// PERF: Only rebuild when clear button visibility changes
final hasText = value.isNotEmpty;
if (hasText != _hasSearchText) {
setState(() {
_hasSearchText = hasText;
});
}
_onSearchChanged(value);
},
onSubmitted: (query) => _performSearch(query),
),
),
],
),
),
const SizedBox(width: 8),
// Library-only toggle with circle background
Tooltip(
message: S.of(context)!.libraryOnly,
child: SizedBox(
width: 44,
height: 44,
child: Material(
color: _libraryOnly
? colorScheme.primary
: colorScheme.surfaceVariant.withOpacity(0.6),
shape: const CircleBorder(),
child: InkWell(
onTap: () {
setState(() {
_libraryOnly = !_libraryOnly;
});
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
},
customBorder: const CircleBorder(),
child: Icon(
_libraryOnly ? Icons.library_music : Icons.library_music_outlined,
size: 22,
color: _libraryOnly
? colorScheme.onPrimary
: colorScheme.onSurface,
),
),
),
),
),
],
),
),
),
@@ -553,7 +649,7 @@ class SearchScreenState extends State<SearchScreen> {
size: 80,
color: colorScheme.onBackground.withOpacity(0.3),
),
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.searchForContent,
style: TextStyle(
@@ -606,7 +702,7 @@ class SearchScreenState extends State<SearchScreen> {
size: 64,
color: colorScheme.error.withOpacity(0.7),
),
const SizedBox(height: 16),
Spacing.vGap16,
Text(
_searchError!,
style: TextStyle(
@@ -615,7 +711,7 @@ class SearchScreenState extends State<SearchScreen> {
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Spacing.vGap16,
FilledButton.tonal(
onPressed: () => _performSearch(_searchController.text),
child: Text(S.of(context)!.retry),
@@ -736,7 +832,7 @@ class SearchScreenState extends State<SearchScreen> {
end: Alignment.bottomCenter,
colors: [
colorScheme.background,
colorScheme.surface.withOpacity(0),
colorScheme.background.withOpacity(0),
],
),
),
@@ -750,151 +846,15 @@ class SearchScreenState extends State<SearchScreen> {
);
}
/// Calculate relevance score for a media item based on query match
/// Calculate relevance score for a media item based on query match.
///
/// Uses advanced scoring with:
/// - Stopword removal ("the Ramones" finds "Ramones")
/// - Fuzzy matching (typo tolerance via Jaro-Winkler)
/// - N-gram matching (partial matches)
/// - Reverse matching (query contains result name)
double _calculateRelevanceScore(MediaItem item, String query) {
final queryLower = query.toLowerCase().trim();
if (queryLower.isEmpty) return 0;
double score = 0;
final nameLower = item.name.toLowerCase();
// Primary name matching
if (nameLower == queryLower) {
score = 100; // Exact match
} else if (nameLower.startsWith(queryLower)) {
score = 80; // Starts with query
} else if (_matchesWordBoundary(nameLower, queryLower)) {
score = 60; // Contains query at word boundary
} else if (nameLower.contains(queryLower)) {
score = 40; // Contains query anywhere
} else {
score = 20; // Fuzzy/partial match (MA returned it, so some relevance)
}
// Bonus for library items (Album has inLibrary property)
if (item is Album && item.inLibrary) {
score += 10;
}
// Bonus for favorites
if (item.favorite == true) {
score += 5;
}
// Secondary field matching (artist/author/creator name)
if (item is Album) {
final artistLower = item.artistsString.toLowerCase();
if (artistLower == queryLower) {
score += 15; // Artist exact match
} else if (artistLower.contains(queryLower)) {
score += 8; // Artist contains query
}
} else if (item is Track) {
final artistLower = item.artistsString.toLowerCase();
if (artistLower == queryLower) {
score += 15;
} else if (artistLower.contains(queryLower)) {
score += 8;
}
// Also check album name for tracks
if (item.album?.name != null) {
final albumLower = item.album!.name.toLowerCase();
if (albumLower.contains(queryLower)) {
score += 5;
}
}
} else if (item is Audiobook) {
// Check audiobook author names
final authorLower = item.authorsString.toLowerCase();
if (authorLower == queryLower) {
score += 15; // Author exact match
} else if (authorLower.contains(queryLower)) {
score += 8; // Author contains query
}
// Also check narrator names
final narratorLower = item.narratorsString.toLowerCase();
if (narratorLower.contains(queryLower)) {
score += 5;
}
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
// Check podcast metadata for author/creator
final metadata = item.metadata;
bool foundCreatorMatch = false;
if (metadata != null) {
final author = (metadata['author'] as String? ?? '').toLowerCase();
final publisher = (metadata['publisher'] as String? ?? '').toLowerCase();
final owner = (metadata['owner'] as String? ?? '').toLowerCase();
final creator = (metadata['creator'] as String? ?? '').toLowerCase();
// Check all possible creator fields
final creatorFields = [author, publisher, owner, creator].where((s) => s.isNotEmpty);
bool foundExact = false;
bool foundContains = false;
for (final field in creatorFields) {
if (field == queryLower) {
foundExact = true;
break;
} else if (field.contains(queryLower)) {
foundContains = true;
}
}
if (foundExact) {
score += 15; // Creator exact match
foundCreatorMatch = true;
} else if (foundContains) {
score += 8; // Creator contains query
foundCreatorMatch = true;
}
// Also check description for keyword matches
final description = (metadata['description'] as String? ?? '').toLowerCase();
if (description.contains(queryLower)) {
score += 5; // Increased from 3 to match other media types
}
}
// Fallback: If no creator metadata matched, but query matches strongly in name,
// give a boost. Podcast names often include the host's name (e.g., "The Louis Theroux Podcast")
if (!foundCreatorMatch && nameLower.contains(queryLower)) {
// Multi-word query that matches in name suggests creator/host name
if (queryLower.contains(' ')) {
// Calculate how prominent the query is in the name
// e.g., "Louis Theroux Interviews" with query "Louis Theroux" = 14/23 = ~60%
final prominence = queryLower.length / nameLower.length;
if (prominence >= 0.5) {
score += 15; // Query is >50% of name - very strong signal
} else if (prominence >= 0.3) {
score += 12; // Query is 30-50% of name - strong signal
} else {
score += 8; // Query in name but less prominent
}
} else {
score += 5; // Single word match
}
}
}
return score;
}
/// Check if query matches at a word boundary in text
/// Handles both single-word and multi-word queries
bool _matchesWordBoundary(String text, String query) {
// For multi-word queries, check if query appears at start of a word in text
// e.g., "The Louis Theroux Podcast" contains "louis theroux" at word boundary
if (query.contains(' ')) {
// Check if text starts with query or contains query after whitespace
if (text.startsWith(query)) return true;
if (text.contains(' $query')) return true;
return false;
}
// For single-word queries, check individual words
final words = text.split(RegExp(r'\s+'));
for (final word in words) {
if (word.startsWith(query)) return true;
}
return false;
return _searchScorer.scoreItem(item, query);
}
/// Extract artists from tracks/albums that match the query but aren't in direct results
@@ -907,8 +867,10 @@ class SearchScreenState extends State<SearchScreen> {
) {
if (query.isEmpty) return [];
final queryLower = query.toLowerCase();
final queryWords = queryLower.split(RegExp(r'\s+'));
// Use normalizer to get tokens with stopwords removed
// This way "the Beatles" becomes ["beatles"] for better matching
final normalizedQuery = _searchScorer.normalizer.normalizeQuery(query);
final queryWords = normalizedQuery.tokensNoStop;
// Create a set of existing artist identifiers to avoid duplicates
final existingArtistKeys = <String>{};
@@ -942,6 +904,7 @@ class SearchScreenState extends State<SearchScreen> {
}
// Filter candidates: only include if artist name contains any query word
// (stopwords already removed from queryWords)
final crossRefArtists = <Artist>[];
for (final artist in candidateArtists.values) {
final artistLower = artist.name.toLowerCase();
@@ -1063,7 +1026,7 @@ class SearchScreenState extends State<SearchScreen> {
return items;
}
/// Build joined segmented filter selector (like library media type selector)
/// Build joined segmented filter selector with animated sliding highlight
Widget _buildFilterSelector(ColorScheme colorScheme) {
final filters = _getAvailableFilters();
final l10n = S.of(context)!;
@@ -1082,42 +1045,93 @@ class SearchScreenState extends State<SearchScreen> {
}
}
// No ClipRRect here - parent container handles clipping with rounded corners
// Ensure GlobalKeys exist for all filters
for (final filter in filters) {
_filterKeys.putIfAbsent(filter, () => GlobalKey());
}
// Wrap in ValueListenableBuilder for efficient rebuilds on filter change
return ValueListenableBuilder<String>(
valueListenable: _activeFilterNotifier,
builder: (context, activeFilter, _) {
return Row(
mainAxisSize: MainAxisSize.min,
children: filters.map((filter) {
final isSelected = activeFilter == filter;
return Material(
// Use theme-aware colors for light/dark mode support
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceVariant.withOpacity(0.6),
child: InkWell(
onTap: () {
// Only update ValueNotifier - no setState needed
_activeFilterNotifier.value = filter;
_animateToFilter(filter);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Text(
getLabel(filter),
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant.withOpacity(0.8),
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
// Measure positions after build
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_measureFilterPositions();
// Update highlight on first build or when filters change
final position = _filterPositions[activeFilter];
final width = _filterWidths[activeFilter];
if (position != null && width != null &&
(_highlightLeft != position || _highlightWidth != width)) {
setState(() {
_highlightLeft = position;
_highlightWidth = width;
});
}
}
});
final selectedIndex = filters.indexOf(activeFilter);
final isFirstTab = selectedIndex == 0;
final isLastTab = selectedIndex == filters.length - 1;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(10),
),
child: IntrinsicHeight(
child: Stack(
children: [
// Animated sliding highlight
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: _highlightLeft + (isFirstTab ? 0 : 2),
width: _highlightWidth - (isFirstTab ? 0 : 2) - (isLastTab ? 0 : 2),
top: 0,
bottom: 0,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
),
),
),
);
}).toList(),
// Filter buttons row (transparent, on top of highlight)
Row(
mainAxisSize: MainAxisSize.min,
children: filters.map((filter) {
final isSelected = activeFilter == filter;
return GestureDetector(
key: _filterKeys[filter],
onTap: () {
_activeFilterNotifier.value = filter;
_animateToFilter(filter);
},
behavior: HitTestBehavior.opaque,
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Text(
getLabel(filter),
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant.withOpacity(0.8),
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
),
);
}).toList(),
),
],
),
),
);
},
);
@@ -1255,7 +1269,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1471,7 +1485,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1665,7 +1679,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1695,37 +1709,58 @@ class SearchScreenState extends State<SearchScreen> {
final isExpanded = _expandedPlaylistId == playlistId;
final isInLib = _isInLibrary(playlist);
// Unique suffix for search context
const heroSuffix = '_search';
return RepaintBoundary(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: ValueKey(playlistId),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
leading: Hero(
tag: HeroTags.playlistCover + playlistId + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.queue_music_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.queue_music_rounded,
color: colorScheme.onSurfaceVariant,
),
),
),
child: imageUrl == null
? Icon(Icons.queue_music_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
playlist.name,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
title: Hero(
tag: HeroTags.playlistTitle + playlistId + heroSuffix,
child: Material(
color: Colors.transparent,
child: Text(
playlist.name,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: showType
? Row(
@@ -1760,13 +1795,14 @@ class SearchScreenState extends State<SearchScreen> {
if (isExpanded) {
setState(() => _expandedPlaylistId = null);
} else {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistDetailsScreen(
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
provider: playlist.provider,
itemId: playlist.itemId,
heroTagSuffix: 'search',
initialImageUrl: imageUrl,
),
),
);
@@ -2008,7 +2044,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -2379,7 +2415,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play radio station: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayRadioStation(e.toString()))),
);
}
}
@@ -2576,7 +2612,7 @@ class SearchScreenState extends State<SearchScreen> {
_logger.log('Error toggling favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update favorite: $e')),
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
);
}
}
@@ -2627,7 +2663,7 @@ class SearchScreenState extends State<SearchScreen> {
// Item is library-only, can't add
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Item is already in library')),
SnackBar(content: Text(S.of(context)!.itemAlreadyInLibrary)),
);
}
return;
@@ -2663,7 +2699,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to add to library')),
SnackBar(content: Text(S.of(context)!.failedToAddToLibrary)),
);
}
}
@@ -2690,7 +2726,7 @@ class SearchScreenState extends State<SearchScreen> {
if (libraryItemId == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot find library ID for removal')),
SnackBar(content: Text(S.of(context)!.cannotFindLibraryId)),
);
return;
}
@@ -2722,7 +2758,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to remove from library')),
SnackBar(content: Text(S.of(context)!.failedToRemoveFromLibrary)),
);
}
}
@@ -2866,7 +2902,7 @@ class SearchScreenState extends State<SearchScreen> {
_logger.log('Error toggling artist favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update favorite: $e')),
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
);
}
}
@@ -2897,7 +2933,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play album: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayAlbum(e.toString()))),
);
}
}
@@ -2921,7 +2957,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${album.name} on ${player.name}'),
content: Text(S.of(context)!.playingOnPlayer(album.name, player.name)),
duration: const Duration(seconds: 1),
),
);
@@ -2929,7 +2965,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play album: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayAlbum(e.toString()))),
);
}
}
@@ -3045,7 +3081,7 @@ class SearchScreenState extends State<SearchScreen> {
_logger.log('Error toggling album favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update favorite: $e')),
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
);
}
}
@@ -3076,7 +3112,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play playlist: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayPlaylist(e.toString()))),
);
}
}
@@ -3100,7 +3136,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${playlist.name} on ${player.name}'),
content: Text(S.of(context)!.playingOnPlayer(playlist.name, player.name)),
duration: const Duration(seconds: 1),
),
);
@@ -3108,7 +3144,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play playlist: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayPlaylist(e.toString()))),
);
}
}
@@ -3135,7 +3171,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${audiobook.name}'),
content: Text(S.of(context)!.playing(audiobook.name)),
duration: const Duration(seconds: 1),
),
);
@@ -3143,7 +3179,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play audiobook: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayAudiobook(e.toString()))),
);
}
}
@@ -3167,7 +3203,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${audiobook.name} on ${player.name}'),
content: Text(S.of(context)!.playingOnPlayer(audiobook.name, player.name)),
duration: const Duration(seconds: 1),
),
);
@@ -3175,7 +3211,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play audiobook: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayAudiobook(e.toString()))),
);
}
}
@@ -3260,7 +3296,7 @@ class SearchScreenState extends State<SearchScreen> {
_logger.log('Error toggling audiobook favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update favorite: $e')),
SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
);
}
}
@@ -3284,7 +3320,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${radio.name} on ${player.name}'),
content: Text(S.of(context)!.playingOnPlayer(radio.name, player.name)),
duration: const Duration(seconds: 1),
),
);
@@ -3292,7 +3328,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play radio station: $e')),
SnackBar(content: Text(S.of(context)!.failedToPlayRadioStation(e.toString()))),
);
}
}
+92 -143
View File
@@ -29,22 +29,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
bool _showFavoritePodcasts = false;
// Audiobook home rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
bool _showDiscoverSeries = false;
// Home row order
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Audiobook libraries
List<Map<String, String>> _discoveredLibraries = [];
Map<String, bool> _libraryEnabled = {};
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
bool _volumePrecisionMode = true;
// Hint settings
bool _showHints = true;
// Library settings
bool _showOnlyArtistsWithAlbums = false;
@override
void initState() {
@@ -70,6 +69,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
// Load audiobook home row settings
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
@@ -79,26 +81,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load home row order
final rowOrder = await SettingsService.getHomeRowOrder();
// Load audiobook library settings
final discovered = await SettingsService.getDiscoveredAbsLibraries() ?? [];
final enabled = await SettingsService.getEnabledAbsLibraries();
final libraryEnabled = <String, bool>{};
for (final lib in discovered) {
final path = lib['path'] ?? '';
// null means all enabled
libraryEnabled[path] = enabled == null || enabled.contains(path);
}
// Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings
final showHints = await SettingsService.getShowHints();
// Load library settings
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -107,16 +97,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContinueAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveredLibraries = discovered;
_libraryEnabled = libraryEnabled;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
});
}
}
@@ -150,6 +141,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
case 'favorite-tracks':
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
case 'favorite-playlists':
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
case 'favorite-radio-stations':
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
case 'favorite-podcasts':
return {'title': s.favoritePodcasts, 'subtitle': s.showFavoritePodcasts};
default:
return {'title': rowId, 'subtitle': ''};
}
@@ -176,6 +173,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
return _showFavoriteArtists;
case 'favorite-tracks':
return _showFavoriteTracks;
case 'favorite-playlists':
return _showFavoritePlaylists;
case 'favorite-radio-stations':
return _showFavoriteRadioStations;
case 'favorite-podcasts':
return _showFavoritePodcasts;
default:
return false;
}
@@ -221,6 +224,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteTracks = value;
SettingsService.setShowFavoriteTracks(value);
break;
case 'favorite-playlists':
_showFavoritePlaylists = value;
SettingsService.setShowFavoritePlaylists(value);
break;
case 'favorite-radio-stations':
_showFavoriteRadioStations = value;
SettingsService.setShowFavoriteRadioStations(value);
break;
case 'favorite-podcasts':
_showFavoritePodcasts = value;
SettingsService.setShowFavoritePodcasts(value);
break;
}
});
}
@@ -287,13 +302,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Logo - same size as login screen (50% of screen width)
// Use color filter to make logo dark in light theme
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 48.0),
child: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
child: Theme.of(context).brightness == Brightness.light
? ColorFiltered(
colorFilter: const ColorFilter.mode(
Color(0xFF1a1a1a), // Dark color for light theme
BlendMode.srcIn,
),
child: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
)
: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
),
// Connection status box - centered with border radius like theme boxes
@@ -600,7 +628,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
RadioListTile<String?>(
title: Text(S.of(context)!.system),
subtitle: const Text('Auto'),
subtitle: Text(S.of(context)!.auto),
value: null,
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
@@ -609,7 +637,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
RadioListTile<String?>(
title: const Text('English'),
title: Text(S.of(context)!.english),
value: 'en',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
@@ -618,7 +646,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
RadioListTile<String?>(
title: const Text('Deutsch'),
title: Text(S.of(context)!.german),
value: 'de',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
@@ -627,7 +655,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
RadioListTile<String?>(
title: const Text('Español'),
title: Text(S.of(context)!.spanish),
value: 'es',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
@@ -635,6 +663,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: Text(S.of(context)!.french),
value: 'fr',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('fr'));
Navigator.pop(dialogContext);
},
),
],
),
);
@@ -708,6 +745,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Volume precision mode',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hold still while adjusting volume for fine control',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _volumePrecisionMode,
onChanged: (value) {
setState(() => _volumePrecisionMode = value);
SettingsService.setVolumePrecisionMode(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
@@ -751,49 +813,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Library section
Text(
S.of(context)!.library,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Show only artists with albums',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hide artists that have no albums in your library',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 12,
),
),
value: _showOnlyArtistsWithAlbums,
onChanged: (value) async {
setState(() => _showOnlyArtistsWithAlbums = value);
await SettingsService.setShowOnlyArtistsWithAlbums(value);
// Force sync library to apply the new filter at API level
if (mounted) {
context.read<MusicAssistantProvider>().forceLibrarySync();
}
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
// Home Screen section
Text(
S.of(context)!.homeScreen,
@@ -970,76 +989,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Audiobook Libraries section
if (_discoveredLibraries.isNotEmpty) ...[
Text(
S.of(context)!.audiobookLibraries,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.chooseAudiobookLibraries,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: _discoveredLibraries.asMap().entries.map((entry) {
final index = entry.key;
final library = entry.value;
final path = library['path'] ?? '';
final name = library['name'] ?? S.of(context)!.unknownLibrary;
final isEnabled = _libraryEnabled[path] ?? true;
return Column(
children: [
SwitchListTile(
title: Text(
name,
style: TextStyle(color: colorScheme.onSurface),
),
value: isEnabled,
onChanged: (value) async {
setState(() {
_libraryEnabled[path] = value;
});
await SettingsService.toggleAbsLibrary(path, value);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.pullToRefresh),
duration: const Duration(seconds: 2),
),
);
}
},
activeColor: colorScheme.primary,
),
if (index < _discoveredLibraries.length - 1)
Divider(
height: 1,
indent: 16,
endIndent: 16,
color: colorScheme.onSurface.withOpacity(0.1),
),
],
);
}).toList(),
),
),
const SizedBox(height: 32),
],
SizedBox(
width: double.infinity,
height: 50,
+91
View File
@@ -20,9 +20,15 @@ class CacheService {
List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists;
List<Album>? _cachedDiscoverAlbums;
List<Audiobook>? _cachedInProgressAudiobooks;
List<Audiobook>? _cachedDiscoverAudiobooks;
List<AudiobookSeries>? _cachedDiscoverSeries;
DateTime? _recentAlbumsLastFetched;
DateTime? _discoverArtistsLastFetched;
DateTime? _discoverAlbumsLastFetched;
DateTime? _inProgressAudiobooksLastFetched;
DateTime? _discoverAudiobooksLastFetched;
DateTime? _discoverSeriesLastFetched;
// Detail screen caching
final Map<String, List<Track>> _albumTracksCache = {};
@@ -112,11 +118,82 @@ class CacheService {
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
}
/// Check if in-progress audiobooks cache is valid
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedInProgressAudiobooks != null &&
_inProgressAudiobooksLastFetched != null &&
now.difference(_inProgressAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached in-progress audiobooks
List<Audiobook>? getCachedInProgressAudiobooks() => _cachedInProgressAudiobooks;
/// Set cached in-progress audiobooks
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
_cachedInProgressAudiobooks = audiobooks;
_inProgressAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
}
/// Check if discover audiobooks cache is valid
bool isDiscoverAudiobooksCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoverAudiobooks != null &&
_discoverAudiobooksLastFetched != null &&
now.difference(_discoverAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discover audiobooks
List<Audiobook>? getCachedDiscoverAudiobooks() => _cachedDiscoverAudiobooks;
/// Set cached discover audiobooks
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
_cachedDiscoverAudiobooks = audiobooks;
_discoverAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
}
/// Check if discover series cache is valid
bool isDiscoverSeriesCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoverSeries != null &&
_discoverSeriesLastFetched != null &&
now.difference(_discoverSeriesLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discover series
List<AudiobookSeries>? getCachedDiscoverSeries() => _cachedDiscoverSeries;
/// Set cached discover series
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
_cachedDiscoverSeries = series;
_discoverSeriesLastFetched = DateTime.now();
_logger.log('✅ Cached ${series.length} discover series');
}
/// Invalidate audiobook caches
void invalidateAudiobookCaches() {
_cachedInProgressAudiobooks = null;
_inProgressAudiobooksLastFetched = null;
_cachedDiscoverAudiobooks = null;
_discoverAudiobooksLastFetched = null;
_cachedDiscoverSeries = null;
_discoverSeriesLastFetched = null;
_logger.log('🗑️ Audiobook caches invalidated');
}
/// Invalidate home screen cache (call on pull-to-refresh)
void invalidateHomeCache() {
_recentAlbumsLastFetched = null;
_discoverArtistsLastFetched = null;
_discoverAlbumsLastFetched = null;
_inProgressAudiobooksLastFetched = null;
_discoverAudiobooksLastFetched = null;
_discoverSeriesLastFetched = null;
_logger.log('🗑️ Home screen cache invalidated');
}
@@ -136,6 +213,20 @@ class CacheService {
_logger.log('🗑️ Home artist caches invalidated');
}
/// Clear discover caches (call when provider filter changes)
/// This ensures discover rows are refetched with the new provider filter
void clearDiscoverCaches() {
_cachedDiscoverArtists = null;
_discoverArtistsLastFetched = null;
_cachedDiscoverAlbums = null;
_discoverAlbumsLastFetched = null;
_cachedRecentAlbums = null;
_recentAlbumsLastFetched = null;
_searchCache.clear();
_searchCacheTime.clear();
_logger.log('🗑️ Discover caches cleared for provider filter change');
}
// ============================================================================
// DETAIL SCREEN CACHING
// ============================================================================
+26
View File
@@ -107,15 +107,18 @@ class DatabaseService {
// ============================================
/// Cache an item with JSON serialization
/// [sourceProvider] - optional provider instance ID that provided this item (for client-side filtering)
Future<void> cacheItem<T>({
required String itemType,
required String itemId,
required Map<String, dynamic> data,
String? sourceProvider,
}) {
return db.cacheItem(
itemType: itemType,
itemId: itemId,
data: jsonEncode(data),
sourceProvider: sourceProvider,
);
}
@@ -132,6 +135,29 @@ class DatabaseService {
}).where((item) => item.isNotEmpty).toList();
}
/// Get cached items with source provider info for client-side filtering
/// Returns list of (data, sourceProviders) tuples
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType) async {
final items = await db.getCachedItems(itemType);
return items.map((item) {
try {
final data = jsonDecode(item.data) as Map<String, dynamic>;
List<String> providers = [];
try {
if (item.sourceProviders.isNotEmpty && item.sourceProviders != '[]') {
providers = List<String>.from(
jsonDecode(item.sourceProviders) as List,
);
}
} catch (_) {}
return (data, providers);
} catch (e) {
_logger.log('Error decoding cached item: $e');
return (<String, dynamic>{}, <String>[]);
}
}).where((item) => item.$1.isNotEmpty).toList();
}
/// Get a single cached item
Future<Map<String, dynamic>?> getCachedItem(String itemType, String itemId) async {
final item = await db.getCachedItem(itemType, itemId);
+350 -77
View File
@@ -326,120 +326,301 @@ class MetadataService {
return null;
}
/// Fetches author image URL from multiple sources
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
/// Returns the image URL if found, null otherwise
static Future<String?> getAuthorImageUrl(String authorName) async {
// Check cache first
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) {
return _authorImageCache[cacheKey];
/// Generate name variations for fuzzy matching
/// Returns a list of name variations to try, in priority order
static List<String> _generateNameVariations(String authorName) {
final variations = <String>[];
final original = authorName.trim();
// Always try original first
variations.add(original);
// Handle "Lastname, Firstname" format
if (original.contains(',')) {
final parts = original.split(',').map((p) => p.trim()).toList();
if (parts.length == 2) {
variations.add('${parts[1]} ${parts[0]}'); // "Firstname Lastname"
}
}
// Try Audnexus (specifically for audiobook authors, uses Audible data)
// Normalize and expand initials for better matching
// Handles messy formats like "J. R.R Tolkien", "J.R.R. Tolkien", etc.
String normalized = original;
// First, normalize spacing around periods in initials: "R.R" -> "R. R", "J. R" -> "J. R"
// Match single letter followed by period and optional space, then another letter
normalized = normalized.replaceAllMapped(
RegExp(r'([A-Z])\.(\s?)([A-Z])'),
(m) => '${m.group(1)}. ${m.group(3)}',
);
// Also handle single letters without periods followed by period-letter: "R.R" in "J. R.R"
// This catches the case where we have "R.R" as a unit
normalized = normalized.replaceAllMapped(
RegExp(r'(\s)([A-Z])\.([A-Z])'),
(m) => '${m.group(1)}${m.group(2)}. ${m.group(3)}.',
);
// Add periods to standalone single uppercase letters (initials without periods)
// "J. R. R Tolkien" -> "J. R. R. Tolkien"
normalized = normalized.replaceAllMapped(
RegExp(r'(\s)([A-Z])(\s)'),
(m) => '${m.group(1)}${m.group(2)}.${m.group(3)}',
);
if (normalized != original && !variations.contains(normalized)) {
variations.add(normalized);
}
// Remove common titles
final titles = ['Dr.', 'Dr', 'Mr.', 'Mr', 'Mrs.', 'Mrs', 'Ms.', 'Ms',
'Prof.', 'Prof', 'Professor', 'Sir', 'Dame', 'Lord', 'Lady'];
String withoutTitle = original;
for (final title in titles) {
if (withoutTitle.toLowerCase().startsWith(title.toLowerCase() + ' ')) {
withoutTitle = withoutTitle.substring(title.length).trim();
if (withoutTitle != original) {
variations.add(withoutTitle);
}
break;
}
}
// Remove suffixes
final suffixes = [', Jr.', ', Jr', ' Jr.', ' Jr', ', Sr.', ', Sr', ' Sr.', ' Sr',
', III', ' III', ', II', ' II', ', IV', ' IV', ', PhD', ', Ph.D.'];
String withoutSuffix = withoutTitle;
for (final suffix in suffixes) {
if (withoutSuffix.toLowerCase().endsWith(suffix.toLowerCase())) {
withoutSuffix = withoutSuffix.substring(0, withoutSuffix.length - suffix.length).trim();
if (withoutSuffix != withoutTitle && !variations.contains(withoutSuffix)) {
variations.add(withoutSuffix);
}
break;
}
}
// Split into parts for further variations (use normalized name for better splitting)
final nameToSplit = normalized != original ? normalized : withoutSuffix;
final parts = nameToSplit.split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
if (parts.length >= 2) {
// Try first + last only (skip middle names/initials)
final firstLast = '${parts.first} ${parts.last}';
if (!variations.contains(firstLast)) {
variations.add(firstLast);
}
// Check if name starts with initials (single letter or letter with period)
bool isInitial(String s) => s.length == 1 || (s.length == 2 && s.endsWith('.'));
// If first part looks like an initial, try just the last name
if (isInitial(parts.first)) {
if (!variations.contains(parts.last)) {
variations.add(parts.last);
}
}
// Handle multiple initials like "J. K. Rowling" or "J. R. R. Tolkien"
// Find where initials end and actual name begins
int initialsEnd = 0;
for (int i = 0; i < parts.length - 1; i++) {
if (isInitial(parts[i])) {
initialsEnd = i + 1;
} else {
break;
}
}
if (initialsEnd > 0 && initialsEnd < parts.length) {
// Try the non-initial part of the name
final restOfName = parts.sublist(initialsEnd).join(' ');
if (restOfName.isNotEmpty && !variations.contains(restOfName)) {
variations.add(restOfName);
}
// Also try initials without periods + last name (e.g., "JRR Tolkien", "JK Rowling")
final initialsNoPeriods = parts.sublist(0, initialsEnd)
.map((s) => s.replaceAll('.', ''))
.join('');
final compactInitials = '$initialsNoPeriods ${parts.last}';
if (!variations.contains(compactInitials)) {
variations.add(compactInitials);
}
}
}
// Remove duplicates while preserving order
return variations.toSet().toList();
}
/// Try to fetch author image from Audnexus
/// Audnexus requires a two-step process:
/// 1. Search by name to get the ASIN
/// 2. Fetch full author details by ASIN to get the image
static Future<String?> _tryAudnexus(String name) async {
try {
final audnexusUri = Uri.https(
'api.audnex.us',
'/authors',
{'name': authorName},
);
// Step 1: Search for author to get ASIN
final searchUri = Uri.https('api.audnex.us', '/authors', {'name': name});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
final audnexusResponse = await http.get(audnexusUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
if (searchData is List && searchData.isNotEmpty) {
// Find the best match - prefer exact name matches
String? bestAsin;
final nameLower = name.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
if (audnexusResponse.statusCode == 200) {
final data = json.decode(audnexusResponse.body);
// Audnexus returns an array of authors
if (data is List && data.isNotEmpty) {
final author = data[0];
final imageUrl = author['image'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
_authorImageCache[cacheKey] = imageUrl;
return imageUrl;
for (final result in searchData) {
final resultName = (result['name'] as String?)?.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
final asin = result['asin'] as String?;
if (asin != null) {
if (resultName == nameLower) {
bestAsin = asin;
break; // Exact match found
}
bestAsin ??= asin; // Keep first result as fallback
}
}
if (bestAsin != null) {
// Step 2: Fetch full author details by ASIN
final detailsUri = Uri.https('api.audnex.us', '/authors/$bestAsin');
final detailsResponse = await http.get(detailsUri).timeout(const Duration(seconds: 5));
if (detailsResponse.statusCode == 200) {
final detailsData = json.decode(detailsResponse.body);
final imageUrl = detailsData['image'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
return imageUrl;
}
}
}
}
}
} catch (e) {
_logger.warning('Audnexus author image error: $e', context: 'Metadata');
_logger.warning('Audnexus author image error for "$name": $e', context: 'Metadata');
}
return null;
}
// Fall back to Open Library API
/// Try to fetch author image from Open Library
static Future<String?> _tryOpenLibrary(String name) async {
try {
final uri = Uri.https(
'openlibrary.org',
'/search/authors.json',
{
'q': authorName,
'limit': '1',
},
);
final uri = Uri.https('openlibrary.org', '/search/authors.json', {
'q': name,
'limit': '1',
});
final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final authors = data['docs'] as List?;
if (authors != null && authors.isNotEmpty) {
final author = authors[0];
final authorKey = author['key'] as String?;
final authorKey = authors[0]['key'] as String?;
if (authorKey != null) {
// Open Library author images are available at:
// https://covers.openlibrary.org/a/olid/{OLID}-L.jpg
final olid = authorKey.replaceAll('/authors/', '');
final imageUrl = 'https://covers.openlibrary.org/a/olid/$olid-L.jpg';
// Verify the image actually exists (Open Library returns 404 for missing images)
// Verify image exists - Open Library returns 302 redirects for valid images
try {
final imageCheck = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
if (imageCheck.statusCode == 200) {
_authorImageCache[cacheKey] = imageUrl;
final check = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
// Accept 200 (direct), 301/302 (redirect to archive.org), or 307/308 (temporary redirect)
if (check.statusCode == 200 || check.statusCode == 301 ||
check.statusCode == 302 || check.statusCode == 307 || check.statusCode == 308) {
return imageUrl;
}
} catch (_) {
// Image doesn't exist, continue to cache null
} catch (_) {}
}
}
}
} catch (e) {
_logger.warning('Open Library author image error for "$name": $e', context: 'Metadata');
}
return null;
}
/// Try to fetch author image from Wikidata (structured Wikipedia data)
static Future<String?> _tryWikidata(String name) async {
try {
// Search for entity by name
final searchUri = Uri.https('www.wikidata.org', '/w/api.php', {
'action': 'wbsearchentities',
'search': name,
'language': 'en',
'type': 'item',
'limit': '3',
'format': 'json',
});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
final results = searchData['search'] as List?;
// Try each result to find one with an image
for (final result in results ?? []) {
final entityId = result['id'] as String?;
if (entityId == null) continue;
// Get the image property (P18)
final claimsUri = Uri.https('www.wikidata.org', '/w/api.php', {
'action': 'wbgetclaims',
'entity': entityId,
'property': 'P18', // P18 is the "image" property
'format': 'json',
});
final claimsResponse = await http.get(claimsUri).timeout(const Duration(seconds: 5));
if (claimsResponse.statusCode == 200) {
final claimsData = json.decode(claimsResponse.body);
final claims = claimsData['claims'] as Map<String, dynamic>?;
final imageProperty = claims?['P18'] as List?;
if (imageProperty != null && imageProperty.isNotEmpty) {
final imageName = imageProperty[0]['mainsnak']?['datavalue']?['value'] as String?;
if (imageName != null && imageName.isNotEmpty) {
// Convert filename to Wikimedia Commons URL
final encodedName = Uri.encodeComponent(imageName.replaceAll(' ', '_'));
return 'https://commons.wikimedia.org/wiki/Special:FilePath/$encodedName?width=500';
}
}
}
}
}
} catch (e) {
_logger.warning('Open Library author image error: $e', context: 'Metadata');
_logger.warning('Wikidata author image error for "$name": $e', context: 'Metadata');
}
return null;
}
// Fall back to Wikipedia API
/// Try to fetch author image from Wikipedia
static Future<String?> _tryWikipedia(String name) async {
try {
// Search for author on Wikipedia
final searchUri = Uri.https(
'en.wikipedia.org',
'/w/api.php',
{
'action': 'query',
'list': 'search',
'srsearch': '$authorName writer',
'srlimit': '1',
'format': 'json',
},
);
// Search for author - "writer author" suffix helps find the right page
final searchUri = Uri.https('en.wikipedia.org', '/w/api.php', {
'action': 'query',
'list': 'search',
'srsearch': '$name writer author',
'srlimit': '1',
'format': 'json',
});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
final searchResults = searchData['query']?['search'] as List?;
if (searchResults != null && searchResults.isNotEmpty) {
final pageTitle = searchResults[0]['title'] as String?;
final results = searchData['query']?['search'] as List?;
if (results != null && results.isNotEmpty) {
final pageTitle = results[0]['title'] as String?;
if (pageTitle != null) {
// Get page image
final imageUri = Uri.https(
'en.wikipedia.org',
'/w/api.php',
{
'action': 'query',
'titles': pageTitle,
'prop': 'pageimages',
'pithumbsize': '500',
'format': 'json',
},
);
final imageUri = Uri.https('en.wikipedia.org', '/w/api.php', {
'action': 'query',
'titles': pageTitle,
'prop': 'pageimages',
'pithumbsize': '500',
'format': 'json',
});
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
if (imageResponse.statusCode == 200) {
@@ -450,7 +631,6 @@ class MetadataService {
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
final imageUrl = thumbnail?['source'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
_authorImageCache[cacheKey] = imageUrl;
return imageUrl;
}
}
@@ -459,7 +639,100 @@ class MetadataService {
}
}
} catch (e) {
_logger.warning('Wikipedia author image error: $e', context: 'Metadata');
_logger.warning('Wikipedia author image error for "$name": $e', context: 'Metadata');
}
return null;
}
/// Fetches author image URL from multiple sources with fuzzy name matching
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
/// Tries name variations if exact match fails
static Future<String?> getAuthorImageUrl(String authorName) async {
// Check cache first
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) {
return _authorImageCache[cacheKey];
}
// Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
// Try to get image for the first/primary author
if (authorName.contains(',')) {
final authors = authorName.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
// Skip if this looks like "Lastname, Firstname" format or has suffixes
// - Second part is initials (J.R.R., J.K., etc.)
// - Second part is a suffix (Jr., Sr., III, PhD, etc.)
final suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'phd', 'ph.d.', 'md', 'm.d.'];
if (authors.length == 2) {
final secondPart = authors[1].toLowerCase();
// Check if second part is a suffix or looks like initials
if (suffixes.contains(secondPart) ||
RegExp(r'^[a-z]\.?\s*[a-z]?\.?\s*[a-z]?\.?$').hasMatch(secondPart)) {
// This is "Lastname, First" or "Name, Jr." - don't split
} else if (authors[0].split(' ').length > 1 && authors[1].split(' ').length > 1) {
// Both parts have multiple words - likely "Author1, Author2"
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// Don't cache null here - fall through to try the full name
}
} else if (authors.length > 2) {
// Multiple commas = definitely multiple authors
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// Don't cache null - fall through to try full name
}
}
// Generate name variations for fuzzy matching
final variations = _generateNameVariations(authorName);
// Try each source with all variations before moving to next source
// This prioritizes source quality over name variation
// 1. Try Audnexus (best for audiobook authors)
for (final name in variations) {
final result = await _tryAudnexus(name);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// 2. Try Open Library
for (final name in variations) {
final result = await _tryOpenLibrary(name);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// 3. Try Wikidata (structured data, good for finding exact person)
for (final name in variations.take(2)) {
final wikidataResult = await _tryWikidata(name);
if (wikidataResult != null) {
_authorImageCache[cacheKey] = wikidataResult;
return wikidataResult;
}
}
// 4. Try Wikipedia (search-based, good fallback)
for (final name in variations.take(3)) {
final wikiResult = await _tryWikipedia(name);
if (wikiResult != null) {
_authorImageCache[cacheKey] = wikiResult;
return wikiResult;
}
}
// Cache the null result to avoid repeated failed lookups
+314 -60
View File
@@ -8,6 +8,7 @@ import '../constants/network.dart';
import '../constants/timings.dart' show Timings, LibraryConstants;
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/provider_instance.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
import 'device_id_service.dart';
@@ -376,23 +377,31 @@ class MusicAssistantAPI {
String? search,
bool? favoriteOnly,
bool albumArtistsOnly = true,
String? orderBy,
List<String>? providerInstanceIds,
}) async {
try {
final args = {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
'album_artists_only': albumArtistsOnly,
if (orderBy != null) 'order_by': orderBy,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
};
_logger.log('🔍 API getArtists args: $args');
final response = await _sendCommand(
'music/artists/library_items',
args: {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
'album_artists_only': albumArtistsOnly,
},
args: args,
);
final items = response['result'] as List<dynamic>?;
if (items == null) {
return [];
}
_logger.log('🔍 API getArtists returned ${items.length} items');
return items
.map((item) => Artist.fromJson(item as Map<String, dynamic>))
.toList();
@@ -402,7 +411,11 @@ class MusicAssistantAPI {
}
}
Future<List<Artist>> getRandomArtists({int limit = 10, bool albumArtistsOnly = true}) async {
Future<List<Artist>> getRandomArtists({
int limit = 10,
bool albumArtistsOnly = true,
List<String>? providerInstanceIds,
}) async {
try {
final response = await _sendCommand(
'music/artists/library_items',
@@ -410,6 +423,8 @@ class MusicAssistantAPI {
'limit': limit,
'order_by': 'random',
'album_artists_only': albumArtistsOnly,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
},
);
@@ -431,6 +446,8 @@ class MusicAssistantAPI {
String? search,
bool? favoriteOnly,
String? artistId,
String? orderBy,
List<String>? providerInstanceIds,
}) async {
try {
final args = <String, dynamic>{
@@ -439,7 +456,11 @@ class MusicAssistantAPI {
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (artistId != null) 'artist_id': artistId,
if (orderBy != null) 'order_by': orderBy,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
};
_logger.log('🔍 API getAlbums args: $args');
final response = await _sendCommand(
'music/albums/library_items',
@@ -448,6 +469,7 @@ class MusicAssistantAPI {
final items = response['result'] as List<dynamic>?;
if (items == null) return [];
_logger.log('🔍 API getAlbums returned ${items.length} items');
return items
.map((item) => Album.fromJson(item as Map<String, dynamic>))
@@ -465,6 +487,8 @@ class MusicAssistantAPI {
bool? favoriteOnly,
String? artistId,
String? albumId,
String? orderBy,
List<String>? providerInstanceIds,
}) async {
try {
final response = await _sendCommand(
@@ -476,6 +500,9 @@ class MusicAssistantAPI {
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (artistId != null) 'artist': artistId,
if (albumId != null) 'album': albumId,
if (orderBy != null) 'order_by': orderBy,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
},
);
@@ -496,6 +523,7 @@ class MusicAssistantAPI {
int? limit,
int? offset,
bool? favoriteOnly,
String? orderBy,
}) async {
try {
final response = await _sendCommand(
@@ -504,6 +532,7 @@ class MusicAssistantAPI {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (orderBy != null) 'order_by': orderBy,
},
);
@@ -555,6 +584,7 @@ class MusicAssistantAPI {
int? limit,
int? offset,
bool? favoriteOnly,
String? orderBy,
}) async {
try {
final response = await _sendCommand(
@@ -563,6 +593,7 @@ class MusicAssistantAPI {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (orderBy != null) 'order_by': orderBy,
},
);
@@ -719,48 +750,113 @@ class MusicAssistantAPI {
String? search,
bool? favoriteOnly,
String? authorId,
List<String>? providerInstanceIds,
}) async {
try {
// Check if library filtering is enabled
final allAudiobooks = <Audiobook>[];
final seenIds = <String>{};
// Check if library filtering is enabled (for ABS providers)
final enabledLibraries = await SettingsService.getEnabledAbsLibraries();
// If specific libraries are enabled, use browse API for filtering
// Extract ABS provider IDs from library paths
final absProviderIds = <String>{};
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
}
// Otherwise use the faster MA API (returns all audiobooks)
final args = <String, dynamic>{
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (authorId != null) 'author_id': authorId,
};
final response = await _sendCommand(
'music/audiobooks/library_items',
args: args,
);
// Check for error in response
if (response.containsKey('error_code')) {
return [];
}
final items = response['result'] as List<dynamic>?;
if (items == null) return [];
final audiobooks = <Audiobook>[];
for (int i = 0; i < items.length; i++) {
try {
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
audiobooks.add(book);
} catch (e) {
// Skip items that fail to parse
for (final libPath in enabledLibraries) {
final providerEnd = libPath.indexOf('://');
if (providerEnd != -1) {
absProviderIds.add(libPath.substring(0, providerEnd));
}
}
}
return audiobooks;
_logger.log('📚 getAudiobooks: absProviderIds=$absProviderIds, providerInstanceIds=$providerInstanceIds');
// PART 1: Fetch from ABS providers using browse API (for library filtering support)
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
// If specific providers are requested, filter libraries to only matching ABS providers
var librariesToBrowse = enabledLibraries;
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
librariesToBrowse = enabledLibraries.where((libPath) {
final providerEnd = libPath.indexOf('://');
if (providerEnd == -1) return false;
final providerId = libPath.substring(0, providerEnd);
return providerInstanceIds.contains(providerId);
}).toList();
}
if (librariesToBrowse.isNotEmpty) {
final browseBooks = await _getAudiobooksFromBrowse(librariesToBrowse, favoriteOnly: favoriteOnly);
for (final book in browseBooks) {
if (!seenIds.contains(book.itemId)) {
seenIds.add(book.itemId);
allAudiobooks.add(book);
}
}
_logger.log('📚 Got ${browseBooks.length} audiobooks from ABS browse API');
}
}
// PART 2: Fetch from non-ABS providers using library_items API
// This gets audiobooks from Spotify, Plex, Jellyfin, etc.
List<String>? nonAbsProviders;
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
// Filter to only non-ABS providers from the requested list
nonAbsProviders = providerInstanceIds.where((id) => !absProviderIds.contains(id)).toList();
} else if (absProviderIds.isNotEmpty) {
// No specific providers requested, but we have ABS - need to also fetch from non-ABS
// Pass null to get all, then we'll deduplicate
nonAbsProviders = null; // Will fetch all
}
// Only call library_items API if:
// 1. No ABS libraries configured (traditional mode), OR
// 2. We have non-ABS providers to query
final shouldFetchFromApi = enabledLibraries == null ||
enabledLibraries.isEmpty ||
(nonAbsProviders == null && absProviderIds.isNotEmpty) ||
(nonAbsProviders != null && nonAbsProviders.isNotEmpty);
if (shouldFetchFromApi) {
final args = <String, dynamic>{
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (authorId != null) 'author_id': authorId,
if (nonAbsProviders != null && nonAbsProviders.isNotEmpty)
'provider': nonAbsProviders,
};
final response = await _sendCommand(
'music/audiobooks/library_items',
args: args,
);
if (!response.containsKey('error_code')) {
final items = response['result'] as List<dynamic>?;
if (items != null) {
int addedCount = 0;
for (int i = 0; i < items.length; i++) {
try {
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
// Deduplicate by itemId
if (!seenIds.contains(book.itemId)) {
seenIds.add(book.itemId);
allAudiobooks.add(book);
addedCount++;
}
} catch (e) {
// Skip items that fail to parse
}
}
_logger.log('📚 Got $addedCount audiobooks from library_items API');
}
}
}
_logger.log('📚 Total audiobooks: ${allAudiobooks.length}');
return allAudiobooks;
} catch (e, stack) {
_logger.log('Error getting audiobooks: $e');
_logger.log('Stack: $stack');
@@ -1082,7 +1178,7 @@ class MusicAssistantAPI {
for (final item in providerRoot) {
final itemMap = item as Map<String, dynamic>;
final path = itemMap['path'] as String? ?? '';
final name = itemMap['name'] as String? ?? '';
final name = (itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '';
if (path != 'root' && name != '..') {
discoveredLibraries.add({'path': path, 'name': name});
}
@@ -1101,7 +1197,7 @@ class MusicAssistantAPI {
for (final libraryItem in providerRoot) {
final libraryMap = libraryItem as Map<String, dynamic>;
final libraryPath = libraryMap['path'] as String? ?? '';
final libraryName = libraryMap['name'] as String? ?? '';
final libraryName = (libraryMap['name'] as String?) ?? (libraryMap['label'] as String?) ?? '';
// Skip "root" (parent navigation) item
if (libraryPath == 'root' || libraryName == '..') continue;
@@ -1121,7 +1217,7 @@ class MusicAssistantAPI {
// Log library contents to find series
for (final item in libraryContents) {
final itemMap = item as Map<String, dynamic>;
final name = itemMap['name'] as String? ?? '';
final name = (itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '';
final path = itemMap['path'] as String? ?? '';
_logger.log('📚 Library item: name=$name, path=$path');
}
@@ -1129,10 +1225,11 @@ class MusicAssistantAPI {
// Look for "Series" folder in the library
for (final item in libraryContents) {
final itemMap = item as Map<String, dynamic>;
final name = (itemMap['name'] as String? ?? '').toLowerCase();
final name = ((itemMap['name'] as String?) ?? (itemMap['label'] as String?) ?? '').toLowerCase();
final path = itemMap['path'] as String? ?? '';
if (name.contains('series') || path.contains('series')) {
// Check for "series" in name/path, or abbreviated path ending with "/s"
if (name.contains('series') || path.contains('series') || path.endsWith('/s')) {
_logger.log('📚 Found series folder: $path');
// Browse the series folder
@@ -1141,13 +1238,15 @@ class MusicAssistantAPI {
for (final seriesItem in seriesItems) {
final seriesMap = seriesItem as Map<String, dynamic>;
final seriesName = (seriesMap['name'] as String?) ?? (seriesMap['label'] as String?) ?? '';
final seriesPath = seriesMap['path'] as String? ?? '';
// Skip parent navigation
if (seriesMap['path'] == 'root' || seriesMap['name'] == '..') continue;
if (seriesPath == 'root' || seriesName == '..' || seriesPath.endsWith('://')) continue;
// Skip actual audiobook items (we only want series folders)
final mediaType = seriesMap['media_type'] as String?;
if (mediaType == 'audiobook' || mediaType == 'track') continue;
_logger.log('📚 Series: name=${seriesMap['name']}, path=${seriesMap['path']}, media_type=$mediaType');
_logger.log('📚 Series: name=$seriesName, path=$seriesPath, media_type=$mediaType');
allSeries.add(AudiobookSeries.fromJson(seriesMap));
}
break; // Found series in this library, move to next library
@@ -1335,13 +1434,18 @@ class MusicAssistantAPI {
}
/// Get random albums
Future<List<Album>> getRandomAlbums({int limit = 10}) async {
Future<List<Album>> getRandomAlbums({
int limit = 10,
List<String>? providerInstanceIds,
}) async {
try {
final response = await _sendCommand(
'music/albums/library_items',
args: {
'limit': limit,
'order_by': 'random',
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
},
);
@@ -1457,6 +1561,8 @@ class MusicAssistantAPI {
int? offset,
String? search,
bool? favoriteOnly,
String? orderBy,
List<String>? providerInstanceIds,
}) async {
try {
final response = await _sendCommand(
@@ -1466,6 +1572,9 @@ class MusicAssistantAPI {
if (offset != null) 'offset': offset,
if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly,
if (orderBy != null) 'order_by': orderBy,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
},
);
@@ -1487,7 +1596,7 @@ class MusicAssistantAPI {
final response = await _sendCommand(
'music/playlist',
args: {
'provider': provider,
'provider_instance_id_or_domain': provider,
'item_id': itemId,
},
);
@@ -2453,6 +2562,109 @@ class MusicAssistantAPI {
}
}
/// Delete an item from the queue
Future<void> queueCommandDeleteItem(String queueId, String itemId) async {
try {
await _sendCommand(
'player_queues/delete_item',
args: {
'queue_id': queueId,
'item_id_or_index': itemId,
},
);
} catch (e) {
_logger.log('Error deleting queue item: $e');
rethrow;
}
}
/// Move an item in the queue by a relative position shift
/// pos_shift > 0: move down, pos_shift < 0: move up, pos_shift = 0: move to next
Future<void> queueCommandMoveItem(String queueId, String itemId, int posShift) async {
try {
await _sendCommand(
'player_queues/move_item',
args: {
'queue_id': queueId,
'queue_item_id': itemId,
'pos_shift': posShift,
},
);
} catch (e) {
_logger.log('Error moving queue item: $e');
rethrow;
}
}
/// Play a specific item in the queue by its queue_item_id
Future<void> queueCommandPlayIndex(String queueId, String queueItemId) async {
try {
await _sendCommand(
'player_queues/play_index',
args: {
'queue_id': queueId,
'index': queueItemId,
},
);
} catch (e) {
_logger.log('Error playing queue item: $e');
rethrow;
}
}
/// Clear all items from the queue
Future<void> queueCommandClear(String queueId) async {
try {
await _sendCommand(
'player_queues/clear',
args: {
'queue_id': queueId,
},
);
} catch (e) {
_logger.log('Error clearing queue: $e');
rethrow;
}
}
/// Transfer the current queue to another player
/// This moves the entire playback queue from source player to target player,
/// including shuffle and repeat settings.
///
/// [sourceQueueId] - The queue ID of the source player (optional, uses first playing player if null)
/// [targetQueueId] - The queue ID of the target player (required)
/// [autoPlay] - Whether to automatically start playback on the target player (default: true)
Future<void> transferQueue({
String? sourceQueueId,
required String targetQueueId,
bool autoPlay = true,
}) async {
try {
_logger.log('🔄 Transferring queue to player: $targetQueueId (source: ${sourceQueueId ?? "auto"}, autoPlay: $autoPlay)');
final args = <String, dynamic>{
'target_queue_id': targetQueueId,
'auto_play': autoPlay,
};
// Only include source_queue_id if explicitly provided
// When null, MA will use the first playing player
if (sourceQueueId != null) {
args['source_queue_id'] = sourceQueueId;
}
await _sendCommand(
'player_queues/transfer',
args: args,
);
_logger.log('✅ Queue transferred successfully');
} catch (e) {
_logger.log('❌ Error transferring queue: $e');
rethrow;
}
}
// ============================================================================
// BUILT-IN PLAYER MANAGEMENT
// ============================================================================
@@ -2496,6 +2708,23 @@ class MusicAssistantAPI {
return _eventStreams['media_item_deleted']!.stream;
}
/// Stream of queue_updated events (for queue metadata changes)
Stream<Map<String, dynamic>> get queueUpdatedEvents {
if (!_eventStreams.containsKey('queue_updated')) {
_eventStreams['queue_updated'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['queue_updated']!.stream;
}
/// Stream of queue_items_updated events (for queue item add/remove/reorder)
/// This is critical for keeping queue UI in sync, especially in radio mode
Stream<Map<String, dynamic>> get queueItemsUpdatedEvents {
if (!_eventStreams.containsKey('queue_items_updated')) {
_eventStreams['queue_items_updated'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['queue_items_updated']!.stream;
}
/// Register this device as a player with retry logic
/// CRITICAL: This creates a player config in MA's settings.json
/// The server expects: player_id and player_name
@@ -3146,14 +3375,6 @@ class MusicAssistantAPI {
_logger.log('⚠️ Could not load provider manifests: $e');
}
// Fetch provider instances (active providers)
try {
await _sendCommand('providers');
_logger.log('✓ Provider instances loaded');
} catch (e) {
_logger.log('⚠️ Could not load provider instances: $e');
}
_logger.log('✅ Initial state fetch complete');
} catch (e) {
_logger.log('❌ Error fetching state: $e');
@@ -3161,6 +3382,39 @@ class MusicAssistantAPI {
}
}
/// Get all music provider instances configured in Music Assistant
/// Returns only music providers (spotify, tidal, filesystem, etc.) not player/metadata providers
Future<List<ProviderInstance>> getMusicProviders() async {
try {
final response = await _sendCommand('providers');
final result = response['result'];
if (result == null) {
_logger.log('⚠️ providers command returned no result');
return [];
}
final List<dynamic> providers = result is List ? result : [];
final musicProviders = <ProviderInstance>[];
for (final providerData in providers) {
if (providerData is Map<String, dynamic>) {
final instance = ProviderInstance.fromJson(providerData);
// Only include music providers (not player providers, metadata providers, etc.)
if (instance.isMusicProvider) {
musicProviders.add(instance);
}
}
}
_logger.log('🎵 Found ${musicProviders.length} music providers: ${musicProviders.map((p) => p.name).join(", ")}');
return musicProviders;
} catch (e) {
_logger.log('❌ Error getting music providers: $e');
return [];
}
}
/// Get current authenticated user's profile
/// Returns user info including display_name, username, groups, etc.
Future<Map<String, dynamic>?> getCurrentUserInfo() async {
+68 -5
View File
@@ -20,7 +20,17 @@ class SecureStorageService {
// Auth Token (for stream requests)
static Future<String?> getAuthToken() async {
return await _storage.read(key: _keyAuthToken);
try {
return await _storage.read(key: _keyAuthToken);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyAuthToken);
return null;
}
rethrow;
}
}
static Future<void> setAuthToken(String? token) async {
@@ -33,7 +43,17 @@ class SecureStorageService {
// Music Assistant Native Auth Token (long-lived token)
static Future<String?> getMaAuthToken() async {
return await _storage.read(key: _keyMaAuthToken);
try {
return await _storage.read(key: _keyMaAuthToken);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyMaAuthToken);
return null;
}
rethrow;
}
}
static Future<void> setMaAuthToken(String? token) async {
@@ -50,7 +70,18 @@ class SecureStorageService {
// Auth Credentials (serialized auth strategy credentials)
static Future<Map<String, dynamic>?> getAuthCredentials() async {
final json = await _storage.read(key: _keyAuthCredentials);
String? json;
try {
json = await _storage.read(key: _keyAuthCredentials);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyAuthCredentials);
return null;
}
rethrow;
}
if (json == null) return null;
try {
return jsonDecode(json) as Map<String, dynamic>;
@@ -69,7 +100,17 @@ class SecureStorageService {
// Password
static Future<String?> getPassword() async {
return await _storage.read(key: _keyPassword);
try {
return await _storage.read(key: _keyPassword);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyPassword);
return null;
}
rethrow;
}
}
static Future<void> setPassword(String? password) async {
@@ -82,7 +123,17 @@ class SecureStorageService {
// Audiobookshelf API Token
static Future<String?> getAbsApiToken() async {
return await _storage.read(key: _keyAbsApiToken);
try {
return await _storage.read(key: _keyAbsApiToken);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyAbsApiToken);
return null;
}
rethrow;
}
}
static Future<void> setAbsApiToken(String? token) async {
@@ -98,12 +149,21 @@ class SecureStorageService {
await _storage.deleteAll();
}
// Migration completed flag key
static const String _keyMigrationCompleted = 'secure_storage_migration_completed';
/// Migrate credentials from SharedPreferences to secure storage.
/// Call this once during app upgrade to migrate existing users.
/// Uses a flag to skip migration on subsequent runs for performance.
static Future<void> migrateFromSharedPreferences() async {
// Import SharedPreferences for migration
final SharedPreferences prefs = await SharedPreferences.getInstance();
// Skip if migration already completed
if (prefs.getBool(_keyMigrationCompleted) == true) {
return;
}
// Migrate auth token
final authToken = prefs.getString('auth_token');
if (authToken != null && authToken.isNotEmpty) {
@@ -144,5 +204,8 @@ class SecureStorageService {
await setAbsApiToken(absApiToken);
await prefs.remove('abs_api_token');
}
// Mark migration as completed to skip on future runs
await prefs.setBool(_keyMigrationCompleted, true);
}
}
File diff suppressed because it is too large Load Diff
+382 -82
View File
@@ -17,6 +17,7 @@ enum SyncStatus {
/// Service for background library synchronization
/// Loads from database cache first, then syncs from MA API in background
/// Supports per-provider sync for accurate client-side filtering
class SyncService with ChangeNotifier {
static SyncService? _instance;
static final _logger = DebugLogger();
@@ -42,6 +43,13 @@ class SyncService with ChangeNotifier {
List<Audiobook> _cachedAudiobooks = [];
List<Playlist> _cachedPlaylists = [];
// Source provider tracking for client-side filtering
// Maps itemId -> list of provider instance IDs that provided the item
Map<String, List<String>> _albumSourceProviders = {};
Map<String, List<String>> _artistSourceProviders = {};
Map<String, List<String>> _audiobookSourceProviders = {};
Map<String, List<String>> _playlistSourceProviders = {};
// Getters
SyncStatus get status => _status;
String? get lastError => _lastError;
@@ -54,8 +62,15 @@ class SyncService with ChangeNotifier {
bool get hasCache => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
// Source provider getters for client-side filtering
Map<String, List<String>> get albumSourceProviders => _albumSourceProviders;
Map<String, List<String>> get artistSourceProviders => _artistSourceProviders;
Map<String, List<String>> get audiobookSourceProviders => _audiobookSourceProviders;
Map<String, List<String>> get playlistSourceProviders => _playlistSourceProviders;
/// Load library data from database cache (instant)
/// Call this on app startup for immediate data
/// Also loads source provider info for client-side filtering
Future<void> loadFromCache() async {
if (!_db.isInitialized) {
_logger.log('⚠️ Database not initialized, skipping cache load');
@@ -65,52 +80,73 @@ class SyncService with ChangeNotifier {
try {
_logger.log('📦 Loading library from database cache...');
// Load albums from cache
final albumData = await _db.getCachedItems('album');
_cachedAlbums = albumData.map((data) {
// Load albums from cache with source providers
final albumDataWithProviders = await _db.getCachedItemsWithProviders('album');
_cachedAlbums = [];
_albumSourceProviders = {};
for (final (data, providers) in albumDataWithProviders) {
try {
return Album.fromJson(data);
final album = Album.fromJson(data);
_cachedAlbums.add(album);
if (providers.isNotEmpty) {
_albumSourceProviders[album.itemId] = providers;
}
} catch (e) {
_logger.log('⚠️ Failed to parse cached album: $e');
return null;
}
}).whereType<Album>().toList();
}
// Load artists from cache
final artistData = await _db.getCachedItems('artist');
_cachedArtists = artistData.map((data) {
// Load artists from cache with source providers
final artistDataWithProviders = await _db.getCachedItemsWithProviders('artist');
_cachedArtists = [];
_artistSourceProviders = {};
for (final (data, providers) in artistDataWithProviders) {
try {
return Artist.fromJson(data);
final artist = Artist.fromJson(data);
_cachedArtists.add(artist);
if (providers.isNotEmpty) {
_artistSourceProviders[artist.itemId] = providers;
}
} catch (e) {
_logger.log('⚠️ Failed to parse cached artist: $e');
return null;
}
}).whereType<Artist>().toList();
}
// Load audiobooks from cache
final audiobookData = await _db.getCachedItems('audiobook');
_cachedAudiobooks = audiobookData.map((data) {
// Load audiobooks from cache with source providers
final audiobookDataWithProviders = await _db.getCachedItemsWithProviders('audiobook');
_cachedAudiobooks = [];
_audiobookSourceProviders = {};
for (final (data, providers) in audiobookDataWithProviders) {
try {
return Audiobook.fromJson(data);
final audiobook = Audiobook.fromJson(data);
_cachedAudiobooks.add(audiobook);
if (providers.isNotEmpty) {
_audiobookSourceProviders[audiobook.itemId] = providers;
}
} catch (e) {
_logger.log('⚠️ Failed to parse cached audiobook: $e');
return null;
}
}).whereType<Audiobook>().toList();
}
// Load playlists from cache
final playlistData = await _db.getCachedItems('playlist');
_cachedPlaylists = playlistData.map((data) {
// Load playlists from cache with source providers
final playlistDataWithProviders = await _db.getCachedItemsWithProviders('playlist');
_cachedPlaylists = [];
_playlistSourceProviders = {};
for (final (data, providers) in playlistDataWithProviders) {
try {
return Playlist.fromJson(data);
final playlist = Playlist.fromJson(data);
_cachedPlaylists.add(playlist);
if (providers.isNotEmpty) {
_playlistSourceProviders[playlist.itemId] = providers;
}
} catch (e) {
_logger.log('⚠️ Failed to parse cached playlist: $e');
return null;
}
}).whereType<Playlist>().toList();
}
_logger.log('📦 Loaded ${_cachedAlbums.length} albums, ${_cachedArtists.length} artists, '
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists from cache');
_logger.log('📦 Source providers: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists tracked');
notifyListeners();
} catch (e) {
_logger.log('❌ Failed to load from cache: $e');
@@ -119,7 +155,12 @@ class SyncService with ChangeNotifier {
/// Sync library data from MA API in background
/// Updates database cache and notifies listeners when complete
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false}) async {
/// [providerInstanceIds] - list of provider IDs to sync (fetches each provider separately for accurate source tracking)
Future<void> syncFromApi(
MusicAssistantAPI api, {
bool force = false,
List<String>? providerInstanceIds,
}) async {
if (_isSyncing) {
_logger.log('🔄 Sync already in progress, skipping');
return;
@@ -155,33 +196,140 @@ class SyncService with ChangeNotifier {
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums');
// Fetch fresh data from MA API (in parallel for speed)
final results = await Future.wait([
api.getAlbums(limit: 1000),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
api.getAudiobooks(limit: 1000),
api.getPlaylists(limit: 1000),
]);
// Only clear cache for full syncs (no specific providers)
// Partial syncs preserve items from disabled providers for instant toggle-on
final isPartialSync = providerInstanceIds != null && providerInstanceIds.isNotEmpty;
if (!isPartialSync) {
await _db.clearCacheForType('album');
await _db.clearCacheForType('artist');
await _db.clearCacheForType('audiobook');
await _db.clearCacheForType('playlist');
}
final albums = results[0] as List<Album>;
final artists = results[1] as List<Artist>;
final audiobooks = results[2] as List<Audiobook>;
final playlists = results[3] as List<Playlist>;
// Build NEW source tracking maps - don't modify existing until sync is complete
// This prevents UI flicker from partial tracking during sync
final syncingProviders = providerInstanceIds?.toSet() ?? <String>{};
final newAlbumSourceProviders = <String, List<String>>{};
final newArtistSourceProviders = <String, List<String>>{};
final newAudiobookSourceProviders = <String, List<String>>{};
final newPlaylistSourceProviders = <String, List<String>>{};
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
// For partial syncs, copy tracking from non-syncing providers
if (isPartialSync) {
for (final entry in _albumSourceProviders.entries) {
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
if (preserved.isNotEmpty) newAlbumSourceProviders[entry.key] = preserved;
}
for (final entry in _artistSourceProviders.entries) {
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
if (preserved.isNotEmpty) newArtistSourceProviders[entry.key] = preserved;
}
for (final entry in _audiobookSourceProviders.entries) {
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
if (preserved.isNotEmpty) newAudiobookSourceProviders[entry.key] = preserved;
}
for (final entry in _playlistSourceProviders.entries) {
final preserved = entry.value.where((p) => !syncingProviders.contains(p)).toList();
if (preserved.isNotEmpty) newPlaylistSourceProviders[entry.key] = preserved;
}
}
// Clear old cache before saving fresh data (removes stale items)
await _db.clearCacheForType('album');
await _db.clearCacheForType('artist');
await _db.clearCacheForType('audiobook');
await _db.clearCacheForType('playlist');
// Collect all items (deduped by itemId, but tracking all source providers)
final albumMap = <String, Album>{};
final artistMap = <String, Artist>{};
final audiobookMap = <String, Audiobook>{};
final playlistMap = <String, Playlist>{};
// Save to database cache
await _saveAlbumsToCache(albums);
await _saveArtistsToCache(artists);
await _saveAudiobooksToCache(audiobooks);
await _savePlaylistsToCache(playlists);
// If specific providers are requested, sync each separately for accurate source tracking
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
_logger.log('🔒 Per-provider sync for ${providerInstanceIds.length} providers');
for (final providerId in providerInstanceIds) {
_logger.log(' 📡 Syncing provider: $providerId');
// Fetch from this specific provider
final results = await Future.wait([
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]),
api.getAudiobooks(limit: 1000, providerInstanceIds: [providerId]),
api.getPlaylists(limit: 1000, providerInstanceIds: [providerId]),
]);
final albums = results[0] as List<Album>;
final artists = results[1] as List<Artist>;
final audiobooks = results[2] as List<Audiobook>;
final playlists = results[3] as List<Playlist>;
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
// Add to maps and track source provider in NEW tracking maps
for (final album in albums) {
albumMap[album.itemId] = album;
newAlbumSourceProviders.putIfAbsent(album.itemId, () => []);
if (!newAlbumSourceProviders[album.itemId]!.contains(providerId)) {
newAlbumSourceProviders[album.itemId]!.add(providerId);
}
}
for (final artist in artists) {
artistMap[artist.itemId] = artist;
newArtistSourceProviders.putIfAbsent(artist.itemId, () => []);
if (!newArtistSourceProviders[artist.itemId]!.contains(providerId)) {
newArtistSourceProviders[artist.itemId]!.add(providerId);
}
}
for (final audiobook in audiobooks) {
audiobookMap[audiobook.itemId] = audiobook;
newAudiobookSourceProviders.putIfAbsent(audiobook.itemId, () => []);
if (!newAudiobookSourceProviders[audiobook.itemId]!.contains(providerId)) {
newAudiobookSourceProviders[audiobook.itemId]!.add(providerId);
}
}
for (final playlist in playlists) {
playlistMap[playlist.itemId] = playlist;
newPlaylistSourceProviders.putIfAbsent(playlist.itemId, () => []);
if (!newPlaylistSourceProviders[playlist.itemId]!.contains(providerId)) {
newPlaylistSourceProviders[playlist.itemId]!.add(providerId);
}
}
}
} else {
// No provider filter - fetch all at once (faster, but no source tracking)
_logger.log('📡 Fetching from all providers (no source tracking)');
final results = await Future.wait([
api.getAlbums(limit: 1000),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
api.getAudiobooks(limit: 1000),
api.getPlaylists(limit: 1000),
]);
for (final album in results[0] as List<Album>) {
albumMap[album.itemId] = album;
}
for (final artist in results[1] as List<Artist>) {
artistMap[artist.itemId] = artist;
}
for (final audiobook in results[2] as List<Audiobook>) {
audiobookMap[audiobook.itemId] = audiobook;
}
for (final playlist in results[3] as List<Playlist>) {
playlistMap[playlist.itemId] = playlist;
}
}
final albums = albumMap.values.toList();
final artists = artistMap.values.toList();
final audiobooks = audiobookMap.values.toList();
final playlists = playlistMap.values.toList();
_logger.log('📥 Total: ${albums.length} albums, ${artists.length} artists, '
'${audiobooks.length} audiobooks, ${playlists.length} playlists');
// Save to database cache with source provider info (using NEW tracking maps)
await _saveAlbumsToCache(albums, newAlbumSourceProviders);
await _saveArtistsToCache(artists, newArtistSourceProviders);
await _saveAudiobooksToCache(audiobooks, newAudiobookSourceProviders);
await _savePlaylistsToCache(playlists, newPlaylistSourceProviders);
// Update sync metadata
await _db.updateSyncMetadata('albums', albums.length);
@@ -190,14 +338,49 @@ class SyncService with ChangeNotifier {
await _db.updateSyncMetadata('playlists', playlists.length);
// Update in-memory cache
_cachedAlbums = albums;
_cachedArtists = artists;
_cachedAudiobooks = audiobooks;
_cachedPlaylists = playlists;
if (isPartialSync) {
// Partial sync: merge with existing cache to preserve items from disabled providers
final albumIds = albums.map((a) => a.itemId).toSet();
final artistIds = artists.map((a) => a.itemId).toSet();
final audiobookIds = audiobooks.map((a) => a.itemId).toSet();
final playlistIds = playlists.map((p) => p.itemId).toSet();
// Keep items not in current sync, add/update items from sync
_cachedAlbums = [
..._cachedAlbums.where((a) => !albumIds.contains(a.itemId)),
...albums,
];
_cachedArtists = [
..._cachedArtists.where((a) => !artistIds.contains(a.itemId)),
...artists,
];
_cachedAudiobooks = [
..._cachedAudiobooks.where((a) => !audiobookIds.contains(a.itemId)),
...audiobooks,
];
_cachedPlaylists = [
..._cachedPlaylists.where((p) => !playlistIds.contains(p.itemId)),
...playlists,
];
} else {
// Full sync: replace entire cache
_cachedAlbums = albums;
_cachedArtists = artists;
_cachedAudiobooks = audiobooks;
_cachedPlaylists = playlists;
}
// Atomically swap in the new source tracking maps
// This ensures filtering always sees complete data, never partial
_albumSourceProviders = newAlbumSourceProviders;
_artistSourceProviders = newArtistSourceProviders;
_audiobookSourceProviders = newAudiobookSourceProviders;
_playlistSourceProviders = newPlaylistSourceProviders;
_lastSyncTime = DateTime.now();
_status = SyncStatus.completed;
_logger.log('✅ Library sync complete');
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
} catch (e) {
_logger.log('❌ Library sync failed: $e');
_status = SyncStatus.error;
@@ -208,15 +391,28 @@ class SyncService with ChangeNotifier {
}
}
/// Save albums to database cache
Future<void> _saveAlbumsToCache(List<Album> albums) async {
/// Save albums to database cache with source provider tracking
Future<void> _saveAlbumsToCache(List<Album> albums, Map<String, List<String>> sourceProviders) async {
for (final album in albums) {
try {
await _db.cacheItem(
itemType: 'album',
itemId: album.itemId,
data: album.toJson(),
);
final providers = sourceProviders[album.itemId];
// Save with first source provider (merging happens in DB layer)
for (final provider in providers ?? <String>[]) {
await _db.cacheItem(
itemType: 'album',
itemId: album.itemId,
data: album.toJson(),
sourceProvider: provider,
);
}
// If no source providers, still cache the item
if (providers == null || providers.isEmpty) {
await _db.cacheItem(
itemType: 'album',
itemId: album.itemId,
data: album.toJson(),
);
}
} catch (e) {
_logger.log('⚠️ Failed to cache album ${album.name}: $e');
}
@@ -224,15 +420,26 @@ class SyncService with ChangeNotifier {
_logger.log('💾 Saved ${albums.length} albums to cache');
}
/// Save artists to database cache
Future<void> _saveArtistsToCache(List<Artist> artists) async {
/// Save artists to database cache with source provider tracking
Future<void> _saveArtistsToCache(List<Artist> artists, Map<String, List<String>> sourceProviders) async {
for (final artist in artists) {
try {
await _db.cacheItem(
itemType: 'artist',
itemId: artist.itemId,
data: artist.toJson(),
);
final providers = sourceProviders[artist.itemId];
for (final provider in providers ?? <String>[]) {
await _db.cacheItem(
itemType: 'artist',
itemId: artist.itemId,
data: artist.toJson(),
sourceProvider: provider,
);
}
if (providers == null || providers.isEmpty) {
await _db.cacheItem(
itemType: 'artist',
itemId: artist.itemId,
data: artist.toJson(),
);
}
} catch (e) {
_logger.log('⚠️ Failed to cache artist ${artist.name}: $e');
}
@@ -240,15 +447,26 @@ class SyncService with ChangeNotifier {
_logger.log('💾 Saved ${artists.length} artists to cache');
}
/// Save audiobooks to database cache
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks) async {
/// Save audiobooks to database cache with source provider tracking
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks, Map<String, List<String>> sourceProviders) async {
for (final audiobook in audiobooks) {
try {
await _db.cacheItem(
itemType: 'audiobook',
itemId: audiobook.itemId,
data: audiobook.toJson(),
);
final providers = sourceProviders[audiobook.itemId];
for (final provider in providers ?? <String>[]) {
await _db.cacheItem(
itemType: 'audiobook',
itemId: audiobook.itemId,
data: audiobook.toJson(),
sourceProvider: provider,
);
}
if (providers == null || providers.isEmpty) {
await _db.cacheItem(
itemType: 'audiobook',
itemId: audiobook.itemId,
data: audiobook.toJson(),
);
}
} catch (e) {
_logger.log('⚠️ Failed to cache audiobook ${audiobook.name}: $e');
}
@@ -256,15 +474,26 @@ class SyncService with ChangeNotifier {
_logger.log('💾 Saved ${audiobooks.length} audiobooks to cache');
}
/// Save playlists to database cache
Future<void> _savePlaylistsToCache(List<Playlist> playlists) async {
/// Save playlists to database cache with source provider tracking
Future<void> _savePlaylistsToCache(List<Playlist> playlists, Map<String, List<String>> sourceProviders) async {
for (final playlist in playlists) {
try {
await _db.cacheItem(
itemType: 'playlist',
itemId: playlist.itemId,
data: playlist.toJson(),
);
final providers = sourceProviders[playlist.itemId];
for (final provider in providers ?? <String>[]) {
await _db.cacheItem(
itemType: 'playlist',
itemId: playlist.itemId,
data: playlist.toJson(),
sourceProvider: provider,
);
}
if (providers == null || providers.isEmpty) {
await _db.cacheItem(
itemType: 'playlist',
itemId: playlist.itemId,
data: playlist.toJson(),
);
}
} catch (e) {
_logger.log('⚠️ Failed to cache playlist ${playlist.name}: $e');
}
@@ -273,8 +502,9 @@ class SyncService with ChangeNotifier {
}
/// Force a fresh sync (for pull-to-refresh)
Future<void> forceSync(MusicAssistantAPI api) async {
await syncFromApi(api, force: true);
/// [providerInstanceIds] - optional list of provider IDs to filter by (null = all providers)
Future<void> forceSync(MusicAssistantAPI api, {List<String>? providerInstanceIds}) async {
await syncFromApi(api, force: true, providerInstanceIds: providerInstanceIds);
}
/// Clear all cached data
@@ -286,12 +516,82 @@ class SyncService with ChangeNotifier {
_cachedArtists = [];
_cachedAudiobooks = [];
_cachedPlaylists = [];
_albumSourceProviders = {};
_artistSourceProviders = {};
_audiobookSourceProviders = {};
_playlistSourceProviders = {};
_lastSyncTime = null;
_status = SyncStatus.idle;
notifyListeners();
_logger.log('🗑️ Library cache cleared');
}
// ============================================
// Client-side filtering methods
// ============================================
/// Filter albums by source provider (instant, no network)
/// Empty enabledProviderIds = all providers enabled, show everything
/// Items without source tracking are HIDDEN (strict mode) to ensure accurate filtering
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
// Empty set = all providers enabled, show everything
if (enabledProviderIds.isEmpty) {
return _cachedAlbums;
}
return _cachedAlbums.where((album) {
final sources = _albumSourceProviders[album.itemId];
// STRICT MODE: Hide items without tracking to ensure accurate filtering
if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
}
/// Filter artists by source provider (instant, no network)
List<Artist> getArtistsFilteredByProviders(Set<String> enabledProviderIds) {
if (enabledProviderIds.isEmpty) {
return _cachedArtists;
}
return _cachedArtists.where((artist) {
final sources = _artistSourceProviders[artist.itemId];
// STRICT MODE: Hide items without tracking
if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
}
/// Filter audiobooks by source provider (instant, no network)
List<Audiobook> getAudiobooksFilteredByProviders(Set<String> enabledProviderIds) {
if (enabledProviderIds.isEmpty) {
return _cachedAudiobooks;
}
return _cachedAudiobooks.where((audiobook) {
final sources = _audiobookSourceProviders[audiobook.itemId];
// STRICT MODE: Hide items without tracking
if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
}
/// Filter playlists by source provider (instant, no network)
List<Playlist> getPlaylistsFilteredByProviders(Set<String> enabledProviderIds) {
if (enabledProviderIds.isEmpty) {
return _cachedPlaylists;
}
return _cachedPlaylists.where((playlist) {
final sources = _playlistSourceProviders[playlist.itemId];
// STRICT MODE: Hide items without tracking
if (sources == null || sources.isEmpty) return false;
return sources.any((s) => enabledProviderIds.contains(s));
}).toList();
}
/// Check if we have source provider tracking data
bool get hasSourceTracking =>
_albumSourceProviders.isNotEmpty ||
_artistSourceProviders.isNotEmpty ||
_audiobookSourceProviders.isNotEmpty ||
_playlistSourceProviders.isNotEmpty;
/// Get albums (from cache or empty if not loaded)
List<Album> getAlbums() => _cachedAlbums;
+48
View File
@@ -124,3 +124,51 @@ class Durations {
static const Duration normal = Duration(milliseconds: 300);
static const Duration slow = Duration(milliseconds: 500);
}
/// Button size tokens
class ButtonSizes {
ButtonSizes._();
// Touch target sizes (Material Design minimum: 48x48)
static const double sm = 32.0;
static const double md = 36.0;
static const double lg = 44.0;
static const double xl = 48.0;
static const double xxl = 56.0;
// Icon sizes within buttons
static const double iconSm = 16.0;
static const double iconMd = 18.0;
static const double iconLg = 20.0;
static const double iconXl = 24.0;
}
/// Semantic status colors for consistent feedback
/// Use with Theme brightness for proper contrast
class StatusColors {
StatusColors._();
// Success states
static const Color success = Color(0xFF4CAF50);
static const Color successLight = Color(0xFF81C784);
static const Color successDark = Color(0xFF388E3C);
// Error states
static const Color error = Color(0xFFF44336);
static const Color errorLight = Color(0xFFE57373);
static const Color errorDark = Color(0xFFD32F2F);
// Warning states
static const Color warning = Color(0xFFFF9800);
static const Color warningLight = Color(0xFFFFB74D);
static const Color warningDark = Color(0xFFF57C00);
// Info states
static const Color info = Color(0xFF2196F3);
static const Color infoLight = Color(0xFF64B5F6);
static const Color infoDark = Color(0xFF1976D2);
// Favorites (heart icon)
static const Color favorite = Color(0xFFF44336);
static const Color favoriteActive = Color(0xFFE53935);
}
+33 -3
View File
@@ -9,6 +9,7 @@ final _themeLogger = DebugLogger();
/// Global function to update adaptive colors from an image URL
/// This can be called from anywhere in the app (e.g., when tapping an album/artist)
/// Sets isOnDetailScreen=true so nav bar will also use adaptive colors
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return;
@@ -16,7 +17,7 @@ Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUr
final colorSchemes = await PaletteHelper.extractColorSchemes(CachedNetworkImageProvider(imageUrl));
if (colorSchemes != null && context.mounted) {
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2);
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
// Silently fail - colors will update when track plays
@@ -42,6 +43,10 @@ class ThemeProvider extends ChangeNotifier {
ColorScheme? _adaptiveLightScheme;
ColorScheme? _adaptiveDarkScheme;
// Track if we're on a detail screen (vs just player-extracted colors)
// When true, nav bar should use adaptive colors even when player is collapsed
bool _isOnDetailScreen = false;
ThemeProvider() {
_loadSettings();
}
@@ -55,12 +60,14 @@ class ThemeProvider extends ChangeNotifier {
AdaptiveColors? get adaptiveColors => _adaptiveColors;
ColorScheme? get adaptiveLightScheme => _adaptiveLightScheme;
ColorScheme? get adaptiveDarkScheme => _adaptiveDarkScheme;
bool get isOnDetailScreen => _isOnDetailScreen;
/// Get the current adaptive primary color (for bottom nav highlight, etc.)
Color get adaptivePrimaryColor => _adaptiveColors?.primary ?? _customColor;
/// Get the current adaptive surface color (for bottom nav background, etc.)
/// Returns a subtle tinted surface based on the adaptive colors
/// NOTE: Prefer getAdaptiveSurfaceColorFor() which respects light/dark mode
Color? get adaptiveSurfaceColor {
if (_adaptiveColors == null) return null;
// Use the miniPlayer color but darkened for a subtle tinted background
@@ -68,6 +75,20 @@ class ThemeProvider extends ChangeNotifier {
return hsl.withLightness((hsl.lightness * 0.4).clamp(0.08, 0.15)).toColor();
}
/// Get adaptive surface color for the given brightness (light/dark mode aware)
/// Returns the same color as the expanded player background (scheme.surface)
Color? getAdaptiveSurfaceColorFor(Brightness brightness) {
// Pick scheme based on current mode
final scheme = brightness == Brightness.dark
? _adaptiveDarkScheme
: _adaptiveLightScheme;
if (scheme == null) return null;
// Use surface - same as expanded player background (line 1234 in expandable_player.dart)
return scheme.surface;
}
Future<void> _loadSettings() async {
final themeModeString = await SettingsService.getThemeMode();
_themeMode = _parseThemeMode(themeModeString);
@@ -147,10 +168,18 @@ class ThemeProvider extends ChangeNotifier {
}
/// Update adaptive colors from album art
void updateAdaptiveColors(ColorScheme? lightScheme, ColorScheme? darkScheme) {
/// [isFromDetailScreen] - if true, this is from navigating to a detail screen
/// and the nav bar should use adaptive colors even when player is collapsed
void updateAdaptiveColors(ColorScheme? lightScheme, ColorScheme? darkScheme, {bool isFromDetailScreen = false}) {
_adaptiveLightScheme = lightScheme;
_adaptiveDarkScheme = darkScheme;
// Only set detail screen flag if explicitly from a detail screen navigation
// Player-extracted colors should NOT set this flag
if (isFromDetailScreen) {
_isOnDetailScreen = true;
}
// Extract AdaptiveColors from the schemes
if (darkScheme != null) {
_adaptiveColors = AdaptiveColors(
@@ -166,11 +195,12 @@ class ThemeProvider extends ChangeNotifier {
notifyListeners();
}
/// Clear adaptive colors (when no track is playing)
/// Clear adaptive colors (when navigating back from detail screen or no track playing)
void clearAdaptiveColors() {
_adaptiveColors = null;
_adaptiveLightScheme = null;
_adaptiveDarkScheme = null;
_isOnDetailScreen = false;
notifyListeners();
}
}
+137
View File
@@ -0,0 +1,137 @@
import 'dart:math';
/// Fuzzy string matching using Jaro-Winkler similarity.
///
/// Jaro-Winkler is optimized for short strings like artist/song names
/// and gives extra weight to matching prefixes (common typos occur mid-word).
class FuzzyMatcher {
/// Calculate Jaro-Winkler similarity between two strings.
///
/// Returns a value between 0.0 (no similarity) and 1.0 (exact match).
///
/// Jaro-Winkler gives bonus weight to strings that match from the beginning,
/// which is useful for handling typos like "Beetles" vs "Beatles".
double jaroWinklerSimilarity(String s1, String s2) {
if (s1 == s2) return 1.0;
if (s1.isEmpty || s2.isEmpty) return 0.0;
final jaro = _jaroSimilarity(s1, s2);
// Winkler modification: boost score for common prefix
int prefixLength = 0;
const maxPrefix = 4; // Standard Winkler prefix length
final minLen = min(s1.length, s2.length);
final prefixLimit = min(maxPrefix, minLen);
for (int i = 0; i < prefixLimit; i++) {
if (s1[i] == s2[i]) {
prefixLength++;
} else {
break;
}
}
const scalingFactor = 0.1; // Standard Winkler scaling factor
return jaro + (prefixLength * scalingFactor * (1 - jaro));
}
/// Calculate base Jaro similarity.
double _jaroSimilarity(String s1, String s2) {
final s1Len = s1.length;
final s2Len = s2.length;
// Match window: characters within this distance can match
final matchWindow = (max(s1Len, s2Len) / 2 - 1).floor();
if (matchWindow < 0) return 0.0;
final s1Matches = List.filled(s1Len, false);
final s2Matches = List.filled(s2Len, false);
int matches = 0;
int transpositions = 0;
// Find matching characters
for (int i = 0; i < s1Len; i++) {
final start = max(0, i - matchWindow);
final end = min(s2Len, i + matchWindow + 1);
for (int j = start; j < end; j++) {
if (s2Matches[j] || s1[i] != s2[j]) continue;
s1Matches[i] = true;
s2Matches[j] = true;
matches++;
break;
}
}
if (matches == 0) return 0.0;
// Count transpositions
int k = 0;
for (int i = 0; i < s1Len; i++) {
if (!s1Matches[i]) continue;
while (!s2Matches[k]) {
k++;
}
if (s1[i] != s2[k]) transpositions++;
k++;
}
// Jaro similarity formula
return (matches / s1Len +
matches / s2Len +
(matches - transpositions / 2) / matches) /
3;
}
/// Find the best match score between query tokens and text tokens.
///
/// Useful for matching individual words when full string matching fails.
/// Returns the highest similarity score found between any token pair.
double bestTokenMatch(List<String> queryTokens, List<String> textTokens) {
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
double bestScore = 0.0;
for (final queryToken in queryTokens) {
for (final textToken in textTokens) {
final score = jaroWinklerSimilarity(queryToken, textToken);
if (score > bestScore) {
bestScore = score;
// Early exit if we find a perfect match
if (score == 1.0) return 1.0;
}
}
}
return bestScore;
}
/// Check if two strings are a fuzzy match within a threshold.
bool isFuzzyMatch(String s1, String s2, {double threshold = 0.85}) {
return jaroWinklerSimilarity(s1, s2) >= threshold;
}
/// Calculate average token match score.
///
/// For each query token, finds the best matching text token,
/// then averages all best matches. Useful for multi-word queries.
double averageTokenMatch(List<String> queryTokens, List<String> textTokens) {
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
double totalScore = 0.0;
for (final queryToken in queryTokens) {
double bestForToken = 0.0;
for (final textToken in textTokens) {
final score = jaroWinklerSimilarity(queryToken, textToken);
if (score > bestForToken) {
bestForToken = score;
}
}
totalScore += bestForToken;
}
return totalScore / queryTokens.length;
}
}
+96
View File
@@ -0,0 +1,96 @@
/// N-gram based string matching for partial matches.
///
/// Uses character bigrams (2-grams) to find partial string matches
/// when exact and fuzzy matching fail.
class NgramMatcher {
/// Generate character bigrams from text.
///
/// Example: "hello" -> ["he", "el", "ll", "lo"]
///
/// For strings shorter than 2 characters, returns the string itself.
List<String> generateBigrams(String text) {
if (text.length < 2) return text.isNotEmpty ? [text] : [];
final bigrams = <String>[];
for (int i = 0; i < text.length - 1; i++) {
bigrams.add(text.substring(i, i + 2));
}
return bigrams;
}
/// Generate character trigrams from text.
///
/// Example: "hello" -> ["hel", "ell", "llo"]
List<String> generateTrigrams(String text) {
if (text.length < 3) return text.isNotEmpty ? [text] : [];
final trigrams = <String>[];
for (int i = 0; i < text.length - 2; i++) {
trigrams.add(text.substring(i, i + 3));
}
return trigrams;
}
/// Calculate bigram similarity using Dice coefficient.
///
/// Dice coefficient: 2 * |intersection| / (|set1| + |set2|)
/// Returns a value between 0.0 and 1.0.
double bigramSimilarity(String s1, String s2) {
final lower1 = s1.toLowerCase();
final lower2 = s2.toLowerCase();
final bigrams1 = generateBigrams(lower1);
final bigrams2 = generateBigrams(lower2);
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
final set1 = bigrams1.toSet();
final set2 = bigrams2.toSet();
final intersection = set1.intersection(set2).length;
// Dice coefficient
return (2 * intersection) / (set1.length + set2.length);
}
/// Calculate Jaccard similarity between bigram sets.
///
/// Jaccard: |intersection| / |union|
/// More conservative than Dice coefficient.
double jaccardSimilarity(String s1, String s2) {
final lower1 = s1.toLowerCase();
final lower2 = s2.toLowerCase();
final bigrams1 = generateBigrams(lower1).toSet();
final bigrams2 = generateBigrams(lower2).toSet();
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
final intersection = bigrams1.intersection(bigrams2).length;
final union = bigrams1.union(bigrams2).length;
return intersection / union;
}
/// Check if query partially matches text via bigrams.
///
/// Uses Dice coefficient with configurable threshold.
bool hasPartialMatch(String query, String text, {double threshold = 0.5}) {
return bigramSimilarity(query, text) >= threshold;
}
/// Calculate containment coefficient.
///
/// Measures how many of the query's bigrams are in the text.
/// Useful when query is expected to be shorter than text.
/// Returns |intersection| / |query bigrams|
double containmentScore(String query, String text) {
final queryBigrams = generateBigrams(query.toLowerCase()).toSet();
final textBigrams = generateBigrams(text.toLowerCase()).toSet();
if (queryBigrams.isEmpty) return 0.0;
final intersection = queryBigrams.intersection(textBigrams).length;
return intersection / queryBigrams.length;
}
}
+86
View File
@@ -0,0 +1,86 @@
/// Configuration for search scoring weights and thresholds.
///
/// All scores are designed to work with the existing sorting system
/// where higher scores indicate better matches.
class ScoringConfig {
// Base scores for match types (primary name matching)
final double exactMatch;
final double exactMatchNoStopwords;
final double startsWithMatch;
final double startsWithNoStopwords;
final double wordBoundaryMatch;
final double wordBoundaryNoStopwords;
final double reverseContainsMatch;
final double reverseContainsNoStopwords;
final double containsMatch;
final double containsNoStopwords;
final double fuzzyMatchHigh;
final double fuzzyMatchMedium;
final double ngramMatch;
final double baseline;
// Bonuses (additive)
final double libraryBonus;
final double favoriteBonus;
final double artistFieldExactBonus;
final double artistFieldPartialBonus;
final double albumFieldBonus;
final double authorFieldExactBonus;
final double authorFieldPartialBonus;
final double narratorFieldBonus;
final double creatorFieldExactBonus;
final double creatorFieldPartialBonus;
final double descriptionBonus;
// Thresholds
final double fuzzyHighThreshold;
final double fuzzyMediumThreshold;
final double ngramThreshold;
// Minimum lengths for matching
final int minReverseMatchLength;
final int minTokenLength;
const ScoringConfig({
// Primary match scores
this.exactMatch = 100,
this.exactMatchNoStopwords = 95,
this.startsWithMatch = 85,
this.startsWithNoStopwords = 80,
this.wordBoundaryMatch = 70,
this.wordBoundaryNoStopwords = 65,
this.reverseContainsMatch = 60,
this.reverseContainsNoStopwords = 55,
this.containsMatch = 50,
this.containsNoStopwords = 45,
this.fuzzyMatchHigh = 40,
this.fuzzyMatchMedium = 35,
this.ngramMatch = 25,
this.baseline = 20,
// Bonuses
this.libraryBonus = 10,
this.favoriteBonus = 5,
this.artistFieldExactBonus = 15,
this.artistFieldPartialBonus = 8,
this.albumFieldBonus = 5,
this.authorFieldExactBonus = 15,
this.authorFieldPartialBonus = 8,
this.narratorFieldBonus = 5,
this.creatorFieldExactBonus = 15,
this.creatorFieldPartialBonus = 8,
this.descriptionBonus = 5,
// Thresholds
this.fuzzyHighThreshold = 0.90,
this.fuzzyMediumThreshold = 0.85,
this.ngramThreshold = 0.5,
// Minimum lengths
this.minReverseMatchLength = 3,
this.minTokenLength = 3,
});
/// Default configuration
static const ScoringConfig defaults = ScoringConfig();
}
+333
View File
@@ -0,0 +1,333 @@
/// Search scoring system for ranking search results.
///
/// Provides advanced text matching including:
/// - Stopword removal ("the Ramones" finds "Ramones")
/// - Fuzzy matching (typo tolerance)
/// - N-gram matching (partial matches)
/// - Reverse matching (query contains result)
library;
export 'scoring_config.dart';
export 'text_normalizer.dart';
export 'fuzzy_matcher.dart';
export 'ngram_matcher.dart';
import 'dart:math';
import '../../models/media_item.dart';
import 'scoring_config.dart';
import 'text_normalizer.dart';
import 'fuzzy_matcher.dart';
import 'ngram_matcher.dart';
/// Main search scorer that combines all matching strategies.
///
/// Usage:
/// ```dart
/// final scorer = SearchScorer();
/// final score = scorer.scoreItem(mediaItem, query);
/// ```
class SearchScorer {
final TextNormalizer _normalizer;
final FuzzyMatcher _fuzzyMatcher;
final NgramMatcher _ngramMatcher;
final ScoringConfig _config;
// Cache for normalized query (reused across items in same search)
String? _cachedQueryString;
NormalizedQuery? _cachedQuery;
SearchScorer({ScoringConfig? config})
: _normalizer = TextNormalizer(),
_fuzzyMatcher = FuzzyMatcher(),
_ngramMatcher = NgramMatcher(),
_config = config ?? ScoringConfig.defaults;
/// Access to the normalizer for external use
TextNormalizer get normalizer => _normalizer;
/// Clear the query cache (call when starting a new search)
void clearCache() {
_cachedQueryString = null;
_cachedQuery = null;
}
/// Score a media item against a search query.
///
/// Returns a score where higher values indicate better matches.
/// Score components:
/// - Primary name matching (0-100)
/// - Secondary field bonuses (0-20)
/// - Library/favorite bonuses (0-15)
double scoreItem(MediaItem item, String query) {
// Use cached normalized query if available
if (_cachedQueryString != query) {
_cachedQueryString = query;
_cachedQuery = _normalizer.normalizeQuery(query);
}
final nq = _cachedQuery!;
if (nq.isEmpty) return 0;
final nameLower = item.name.toLowerCase();
final nameNoStopwords = _normalizer.normalizeTextNoStopwords(item.name);
// Calculate primary score based on name matching
double score = _calculatePrimaryScore(nameLower, nameNoStopwords, nq);
// Add secondary field bonuses based on media type
score += _calculateSecondaryScore(item, nq);
// Add library/favorite bonuses
score += _calculateBonuses(item);
return score;
}
/// Calculate primary score based on name matching.
double _calculatePrimaryScore(
String nameLower,
String nameNoStopwords,
NormalizedQuery nq,
) {
// Tier 1: Exact match (highest priority)
if (nameLower == nq.normalized) {
return _config.exactMatch;
}
if (nameNoStopwords == nq.withoutStopwords) {
return _config.exactMatchNoStopwords;
}
// Tier 2: Starts with
if (nameLower.startsWith(nq.normalized)) {
return _config.startsWithMatch;
}
if (nameNoStopwords.startsWith(nq.withoutStopwords)) {
return _config.startsWithNoStopwords;
}
// Tier 3: Word boundary match
if (_matchesWordBoundary(nameLower, nq.normalized)) {
return _config.wordBoundaryMatch;
}
if (_matchesWordBoundary(nameNoStopwords, nq.withoutStopwords)) {
return _config.wordBoundaryNoStopwords;
}
// Tier 4: Reverse contains (result name IN query)
// This solves "the ramones" finding "Ramones"
if (_reverseContains(nq.normalized, nameLower)) {
return _config.reverseContainsMatch;
}
if (_reverseContains(nq.withoutStopwords, nameNoStopwords)) {
return _config.reverseContainsNoStopwords;
}
// Tier 5: Contains anywhere
if (nameLower.contains(nq.normalized)) {
return _config.containsMatch;
}
if (nameNoStopwords.contains(nq.withoutStopwords)) {
return _config.containsNoStopwords;
}
// Tier 6: Fuzzy matching (typo tolerance)
final fuzzyScore = _fuzzyMatcher.jaroWinklerSimilarity(
nq.withoutStopwords,
nameNoStopwords,
);
if (fuzzyScore >= _config.fuzzyHighThreshold) {
// Scale score based on similarity (0.90-1.0 -> 40-45)
return _config.fuzzyMatchHigh +
((fuzzyScore - _config.fuzzyHighThreshold) * 50);
}
if (fuzzyScore >= _config.fuzzyMediumThreshold) {
return _config.fuzzyMatchMedium;
}
// Tier 7: Token-level fuzzy (individual words)
final tokenFuzzy = _fuzzyMatcher.bestTokenMatch(
nq.tokensNoStop,
_normalizer.tokenize(nameNoStopwords),
);
if (tokenFuzzy >= _config.fuzzyHighThreshold) {
return _config.fuzzyMatchMedium;
}
// Tier 8: N-gram partial matching
final ngramScore = _ngramMatcher.bigramSimilarity(
nq.withoutStopwords,
nameNoStopwords,
);
if (ngramScore >= _config.ngramThreshold) {
// Scale: 0.5-1.0 -> 25-35
return _config.ngramMatch + (ngramScore * 10);
}
// Baseline: Music Assistant API returned it, so some relevance
return _config.baseline;
}
/// Check if query matches at word boundary in text.
bool _matchesWordBoundary(String text, String query) {
if (query.contains(' ')) {
// Multi-word query: check if at start or after space
if (text.startsWith(query)) return true;
if (text.contains(' $query')) return true;
return false;
}
// Single-word query: check if any word starts with query
final words = text.split(RegExp(r'\s+'));
for (final word in words) {
if (word.startsWith(query)) return true;
}
return false;
}
/// Check if text is contained within query (reverse matching).
///
/// Solves: "the ramones" query finding "Ramones" artist
bool _reverseContains(String query, String text) {
if (text.length < _config.minReverseMatchLength) return false;
// Direct containment
if (query.contains(text)) return true;
// Token-level: any query token equals text
final queryTokens = query.split(RegExp(r'\s+'));
for (final token in queryTokens) {
if (token.length >= _config.minReverseMatchLength && text == token) {
return true;
}
}
return false;
}
/// Calculate secondary field bonuses based on media type.
double _calculateSecondaryScore(MediaItem item, NormalizedQuery nq) {
double bonus = 0;
if (item is Album) {
bonus += _scoreArtistField(item.artistsString, nq);
} else if (item is Track) {
bonus += _scoreArtistField(item.artistsString, nq);
// Also check album name
if (item.album?.name != null) {
final albumLower = item.album!.name.toLowerCase();
if (albumLower.contains(nq.withoutStopwords)) {
bonus += _config.albumFieldBonus;
}
}
} else if (item is Audiobook) {
// Check authors
final authorLower = item.authorsString.toLowerCase();
if (authorLower == nq.withoutStopwords) {
bonus += _config.authorFieldExactBonus;
} else if (authorLower.contains(nq.withoutStopwords)) {
bonus += _config.authorFieldPartialBonus;
}
// Check narrators
final narratorLower = item.narratorsString.toLowerCase();
if (narratorLower.contains(nq.withoutStopwords)) {
bonus += _config.narratorFieldBonus;
}
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
bonus += _scorePodcastFields(item, nq);
}
return bonus;
}
/// Score artist field for albums and tracks.
double _scoreArtistField(String artistsString, NormalizedQuery nq) {
final artistLower = artistsString.toLowerCase();
if (artistLower == nq.withoutStopwords) {
return _config.artistFieldExactBonus;
}
if (artistLower.contains(nq.withoutStopwords)) {
return _config.artistFieldPartialBonus;
}
return 0;
}
/// Score podcast-specific fields (creator, description).
double _scorePodcastFields(MediaItem item, NormalizedQuery nq) {
double bonus = 0;
final metadata = item.metadata;
if (metadata != null) {
// Check creator fields
final creatorFields = [
metadata['author'] as String?,
metadata['publisher'] as String?,
metadata['owner'] as String?,
metadata['creator'] as String?,
].where((s) => s != null && s.isNotEmpty).map((s) => s!.toLowerCase());
bool foundExact = false;
bool foundContains = false;
for (final field in creatorFields) {
if (field == nq.withoutStopwords) {
foundExact = true;
break;
} else if (field.contains(nq.withoutStopwords)) {
foundContains = true;
}
}
if (foundExact) {
bonus += _config.creatorFieldExactBonus;
} else if (foundContains) {
bonus += _config.creatorFieldPartialBonus;
}
// Check description
final description =
(metadata['description'] as String? ?? '').toLowerCase();
if (description.contains(nq.withoutStopwords)) {
bonus += _config.descriptionBonus;
}
}
// Fallback: podcast name prominence check
if (bonus == 0) {
final nameLower = item.name.toLowerCase();
if (nameLower.contains(nq.withoutStopwords)) {
if (nq.withoutStopwords.contains(' ')) {
// Multi-word query prominence
final prominence = nq.withoutStopwords.length / nameLower.length;
if (prominence >= 0.5) {
bonus += _config.creatorFieldExactBonus;
} else if (prominence >= 0.3) {
bonus += _config.creatorFieldPartialBonus + 4;
} else {
bonus += _config.creatorFieldPartialBonus;
}
} else {
bonus += _config.descriptionBonus;
}
}
}
return bonus;
}
/// Calculate library and favorite bonuses.
double _calculateBonuses(MediaItem item) {
double bonus = 0;
// Library bonus (Album has inLibrary property)
if (item is Album && item.inLibrary) {
bonus += _config.libraryBonus;
}
// Favorite bonus
if (item.favorite == true) {
bonus += _config.favoriteBonus;
}
return bonus;
}
}
+114
View File
@@ -0,0 +1,114 @@
/// Text normalization utilities for search scoring.
///
/// Handles query normalization, stopword removal, and tokenization.
/// Represents a normalized search query with various forms for matching.
class NormalizedQuery {
/// Original query as entered by user
final String original;
/// Lowercased and trimmed query
final String normalized;
/// Query with stopwords removed
final String withoutStopwords;
/// Individual tokens from normalized query
final List<String> tokens;
/// Tokens with stopwords removed
final List<String> tokensNoStop;
const NormalizedQuery({
required this.original,
required this.normalized,
required this.withoutStopwords,
required this.tokens,
required this.tokensNoStop,
});
/// Whether the query is empty after normalization
bool get isEmpty => normalized.isEmpty;
/// Whether stopword removal changed the query
bool get hasStopwordsRemoved => normalized != withoutStopwords;
}
/// Normalizes text and queries for search matching.
class TextNormalizer {
/// Common English stopwords relevant to music/media searches.
///
/// Intentionally minimal to avoid over-filtering:
/// - Articles that commonly prefix artist/album names
/// - Common prepositions that add noise
static const Set<String> _stopwords = {
// Articles
'the',
'a',
'an',
// Common prepositions in music/media context
'of',
'and',
'in',
'on',
'at',
'to',
'for',
'with',
'by',
// Common noise words
'is',
'are',
'was',
'be',
};
/// Normalize a search query into multiple forms for matching.
///
/// Returns a [NormalizedQuery] containing:
/// - Original query
/// - Lowercased/trimmed version
/// - Version with stopwords removed
/// - Tokenized versions
NormalizedQuery normalizeQuery(String query) {
final normalized = query.toLowerCase().trim();
final tokens = tokenize(normalized);
final tokensNoStop = tokens.where((t) => !_stopwords.contains(t)).toList();
// If all tokens were stopwords, preserve original tokens
// e.g., "The The" (band name) shouldn't become empty
final effectiveTokensNoStop =
tokensNoStop.isEmpty ? tokens : tokensNoStop;
final withoutStopwords = effectiveTokensNoStop.join(' ');
return NormalizedQuery(
original: query,
normalized: normalized,
withoutStopwords: withoutStopwords,
tokens: tokens,
tokensNoStop: effectiveTokensNoStop,
);
}
/// Normalize text for comparison (lowercase, trim).
String normalizeText(String text) {
return text.toLowerCase().trim();
}
/// Normalize text with stopwords removed.
String normalizeTextNoStopwords(String text) {
final tokens = tokenize(text.toLowerCase().trim());
final filtered = tokens.where((t) => !_stopwords.contains(t)).toList();
return filtered.isEmpty ? text.toLowerCase().trim() : filtered.join(' ');
}
/// Split text into tokens on whitespace.
List<String> tokenize(String text) {
return text.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
}
/// Check if a word is a stopword.
bool isStopword(String word) {
return _stopwords.contains(word.toLowerCase());
}
}
+12 -1
View File
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/album_details_screen.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
@@ -36,6 +37,7 @@ class _AlbumCardState extends State<AlbumCard> {
bool _maImageFailed = false;
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
bool _isNavigating = false;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@@ -117,6 +119,10 @@ class _AlbumCardState extends State<AlbumCard> {
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
// Prevent double-tap navigation
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
@@ -128,7 +134,12 @@ class _AlbumCardState extends State<AlbumCard> {
initialImageUrl: imageUrl,
),
),
);
).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
+7 -12
View File
@@ -38,6 +38,12 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
@override
void initState() {
super.initState();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedAlbums?.call();
if (cached != null && cached.isNotEmpty) {
_albums = cached;
_isLoading = false;
}
_loadAlbums();
}
@@ -45,18 +51,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
if (_hasLoaded) return;
_hasLoaded = true;
// 1. Try to get cached data synchronously for instant display
final cachedAlbums = widget.getCachedAlbums?.call();
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
if (mounted) {
setState(() {
_albums = cachedAlbums;
_isLoading = false;
});
}
}
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
// Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshAlbums = await widget.loadAlbums();
if (mounted && freshAlbums.isNotEmpty) {
+12 -1
View File
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/artist_details_screen.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
@@ -38,6 +39,7 @@ class _ArtistCardState extends State<ArtistCard> {
bool _maImageFailed = false;
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
bool _isNavigating = false;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@@ -110,6 +112,10 @@ class _ArtistCardState extends State<ArtistCard> {
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
// Prevent double-tap navigation
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
@@ -121,7 +127,12 @@ class _ArtistCardState extends State<ArtistCard> {
initialImageUrl: imageUrl,
),
),
);
).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
+7 -12
View File
@@ -38,6 +38,12 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
@override
void initState() {
super.initState();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedArtists?.call();
if (cached != null && cached.isNotEmpty) {
_artists = cached;
_isLoading = false;
}
_loadArtists();
}
@@ -45,18 +51,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
if (_hasLoaded) return;
_hasLoaded = true;
// 1. Try to get cached data synchronously for instant display
final cachedArtists = widget.getCachedArtists?.call();
if (cachedArtists != null && cachedArtists.isNotEmpty) {
if (mounted) {
setState(() {
_artists = cachedArtists;
_isLoading = false;
});
}
}
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
// Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshArtists = await widget.loadArtists();
if (mounted && freshArtists.isNotEmpty) {
+82 -69
View File
@@ -14,6 +14,8 @@ class AudiobookRow extends StatefulWidget {
final Future<List<Audiobook>> Function() loadAudiobooks;
final String? heroTagSuffix;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Audiobook>? Function()? getCachedAudiobooks;
const AudiobookRow({
super.key,
@@ -21,6 +23,7 @@ class AudiobookRow extends StatefulWidget {
required this.loadAudiobooks,
this.heroTagSuffix,
this.rowHeight,
this.getCachedAudiobooks,
});
@override
@@ -28,8 +31,8 @@ class AudiobookRow extends StatefulWidget {
}
class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClientMixin {
late Future<List<Audiobook>> _audiobooksFuture;
List<Audiobook>? _cachedAudiobooks;
List<Audiobook> _audiobooks = [];
bool _isLoading = true;
bool _hasLoaded = false;
bool _hasPrecachedAudiobooks = false;
@@ -39,18 +42,36 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
@override
void initState() {
super.initState();
_loadAudiobooksOnce();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedAudiobooks?.call();
if (cached != null && cached.isNotEmpty) {
_audiobooks = cached;
_isLoading = false;
}
_loadAudiobooks();
}
void _loadAudiobooksOnce() {
if (!_hasLoaded) {
_audiobooksFuture = widget.loadAudiobooks().then((audiobooks) {
_cachedAudiobooks = audiobooks;
// Pre-cache images for smooth Hero animations
_precacheAudiobookImages(audiobooks);
return audiobooks;
});
_hasLoaded = true;
Future<void> _loadAudiobooks() async {
if (_hasLoaded) return;
_hasLoaded = true;
// Load fresh data (always update - fresh data may have updated progress)
try {
final freshAudiobooks = await widget.loadAudiobooks();
if (mounted && freshAudiobooks.isNotEmpty) {
setState(() {
_audiobooks = freshAudiobooks;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheAudiobookImages(freshAudiobooks);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
@@ -80,10 +101,57 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme, MusicAssistantProvider maProvider) {
// Only show loading if we have no data at all
if (_audiobooks.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_audiobooks.isEmpty) {
return Center(
child: Text(
'No audiobooks found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _audiobooks.length,
itemExtent: itemExtent,
cacheExtent: 500,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemBuilder: (context, index) {
final audiobook = _audiobooks[index];
return Container(
key: ValueKey(audiobook.uri ?? audiobook.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: _AudiobookCard(
audiobook: audiobook,
heroTagSuffix: widget.heroTagSuffix ?? 'home_${widget.title.replaceAll(' ', '_').toLowerCase()}',
maProvider: maProvider,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('AudiobookRow:${widget.title}');
super.build(context);
super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final maProvider = context.read<MusicAssistantProvider>();
@@ -109,62 +177,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
),
),
Expanded(
child: FutureBuilder<List<Audiobook>>(
future: _audiobooksFuture,
builder: (context, snapshot) {
final audiobooks = snapshot.data ?? _cachedAudiobooks;
if (audiobooks == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && audiobooks == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (audiobooks == null || audiobooks.isEmpty) {
return Center(
child: Text(
'No audiobooks found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: audiobooks.length,
itemExtent: itemExtent,
cacheExtent: 500,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemBuilder: (context, index) {
final audiobook = audiobooks[index];
return Container(
key: ValueKey(audiobook.uri ?? audiobook.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: _AudiobookCard(
audiobook: audiobook,
heroTagSuffix: widget.heroTagSuffix ?? 'home_${widget.title.replaceAll(' ', '_').toLowerCase()}',
maProvider: maProvider,
),
);
},
),
);
},
),
child: _buildContent(contentHeight, colorScheme, maProvider),
),
],
),
+93 -75
View File
@@ -103,39 +103,48 @@ class EmptyState extends StatelessWidget {
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(
message,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 16,
// Wrap in scrollable to allow PageView horizontal swipe detection
return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: 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(
message,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 16,
),
textAlign: TextAlign.center,
),
if (actionLabel != null && onAction != null) ...[
Spacing.vGap24,
ElevatedButton.icon(
onPressed: onAction,
icon: const Icon(Icons.refresh_rounded),
label: Text(actionLabel!),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
],
),
textAlign: TextAlign.center,
),
if (actionLabel != null && onAction != null) ...[
Spacing.vGap24,
ElevatedButton.icon(
onPressed: onAction,
icon: const Icon(Icons.refresh_rounded),
label: Text(actionLabel!),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
),
);
@@ -162,51 +171,60 @@ class _CustomEmptyState extends EmptyState {
final colorScheme = Theme.of(context).colorScheme;
final s = S.of(context)!;
return Center(
child: Padding(
padding: Spacing.paddingAll24,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: iconSize,
color: colorScheme.onSurface.withOpacity(0.38),
// Wrap in scrollable to allow PageView horizontal swipe detection
return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: 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: Text(s.refresh),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
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: Text(s.refresh),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
),
);
+10 -9
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../l10n/app_localizations.dart';
import '../../services/debug_logger.dart';
import '../../theme/design_tokens.dart';
@@ -72,9 +73,9 @@ class DebugConsole extends StatelessWidget {
padding: EdgeInsets.zero,
minimumSize: const Size(40, 20),
),
child: const Text(
'Clear',
style: TextStyle(fontSize: 10, color: Colors.green),
child: Text(
S.of(context)!.clear,
style: const TextStyle(fontSize: 10, color: Colors.green),
),
),
],
@@ -86,17 +87,17 @@ class DebugConsole extends StatelessWidget {
if (logs.isNotEmpty) {
Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logs copied to clipboard'),
SnackBar(
content: Text(S.of(context)!.logsCopied),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
duration: const Duration(seconds: 2),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No logs to copy'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(S.of(context)!.noLogsToCopy),
duration: const Duration(seconds: 2),
),
);
}
File diff suppressed because it is too large Load Diff
+110 -72
View File
@@ -8,6 +8,7 @@ import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import 'expandable_player.dart';
import 'player/mini_player_content.dart' show MiniPlayerLayout;
import 'player/player_reveal_overlay.dart';
@@ -67,13 +68,14 @@ class BottomSpacing {
static double get 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 colors
class PlayerExpansionState {
final double progress;
final Color? backgroundColor;
PlayerExpansionState(this.progress, this.backgroundColor);
final Color? primaryColor;
PlayerExpansionState(this.progress, this.backgroundColor, this.primaryColor);
}
final playerExpansionNotifier = ValueNotifier<PlayerExpansionState>(PlayerExpansionState(0.0, null));
final playerExpansionNotifier = ValueNotifier<PlayerExpansionState>(PlayerExpansionState(0.0, null, null));
/// Wrapper widget that provides a global player overlay above all navigation.
///
@@ -95,10 +97,31 @@ class GlobalPlayerOverlay extends StatefulWidget {
globalPlayerKey.currentState?.collapse();
}
/// Force collapse player to mini state, instantly closing queue panel if open.
/// Used for queue transfer where we want to go directly to mini player.
static void forceCollapsePlayer() {
globalPlayerKey.currentState?.forceCollapse();
}
/// Check if the player is currently expanded
static bool get isPlayerExpanded =>
globalPlayerKey.currentState?.isExpanded ?? false;
/// Check if the queue panel is currently open (animation value > 0.5)
static bool get isQueuePanelOpen =>
globalPlayerKey.currentState?.isQueuePanelOpen ?? false;
/// Check if the queue panel is intended to be open (target state)
/// Use this for back gesture handling to avoid timing issues during animations
static bool get isQueuePanelTargetOpen =>
globalPlayerKey.currentState?.isQueuePanelTargetOpen ?? false;
/// Close the queue panel if open
/// [withHaptic]: Set to false for Android back gesture (system provides haptic)
static void closeQueuePanel({bool withHaptic = true}) {
globalPlayerKey.currentState?.closeQueuePanel(withHaptic: withHaptic);
}
/// Get the current expansion progress (0.0 to 1.0)
static double get expansionProgress =>
globalPlayerKey.currentState?.expansionProgress ?? 0.0;
@@ -409,15 +432,17 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// Watch provider to trigger rebuilds when connection state changes
final provider = context.watch<MusicAssistantProvider>();
// PERF: Use select() to only rebuild when connection state or player changes
final (isConnected, hasSelectedPlayer) = context.select<MusicAssistantProvider, (bool, bool)>(
(p) => (p.isConnected, p.selectedPlayer != null),
);
// Compute whether this is a first-time user ready for welcome screen.
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
final isFirstTimeUserReady = _settingsLoaded &&
!_hasCompletedOnboarding &&
provider.isConnected &&
provider.selectedPlayer != null;
isConnected &&
hasSelectedPlayer;
// Show welcome backdrop if:
// 1. Already in hint mode (_isHintModeActive), OR
@@ -463,7 +488,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
// Hide when: not connected, no player selected, OR showing welcome screen
// For first-time users, selectedPlayer becomes non-null at the same time
// as shouldShowWelcomeBackdrop becomes true, preventing any gap.
if (provider.isConnected && provider.selectedPlayer != null && !shouldShowWelcomeBackdrop)
if (isConnected && hasSelectedPlayer && !shouldShowWelcomeBackdrop)
Positioned(
left: 0,
right: 0,
@@ -473,74 +498,44 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
builder: (context, _) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
// Use adaptive primary color for bottom nav when adaptive theme is enabled
final sourceColor = themeProvider.adaptiveTheme
? themeProvider.adaptivePrimaryColor
: colorScheme.primary;
// Use cached color computation to avoid expensive HSL operations during scroll
final isDark = Theme.of(context).brightness == Brightness.dark;
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Base background: use adaptive surface color if available, otherwise default surface
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
? themeProvider.adaptiveSurfaceColor!
: colorScheme.surface;
return ValueListenableBuilder<PlayerExpansionState>(
valueListenable: playerExpansionNotifier,
// Pass BottomNavigationBar as child to avoid rebuilding it every frame
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: const Icon(Icons.library_music_outlined),
activeIcon: const Icon(Icons.library_music_rounded),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
builder: (context, expansionState, navBar) {
// Only compute colors during animation - this is the hot path
final navBgColor = expansionState.progress > 0 && expansionState.backgroundColor != null
? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)!
: baseBgColor;
builder: (context, expansionState, _) {
// Nav bar color logic - only use adaptive colors when:
// 1. Player is expanding/expanded, OR
// 2. On a detail screen (isOnDetailScreen)
// On home screen with collapsed player: always use default theme colors
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
// Nav bar background color
final Color navBgColor;
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
// Player is expanding - blend from surface to player's adaptive color
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
} else if (useAdaptiveColors) {
// On a detail screen - use adaptive surface color for nav bar
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
navBgColor = adaptiveBg ?? colorScheme.surface;
} else {
// Home screen with collapsed player - use default surface color
navBgColor = colorScheme.surface;
}
// Icon color - only use adaptive colors when appropriate
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
? themeProvider.adaptiveColors!.primary
: colorScheme.primary;
// Blend icon color with player's primary color during expansion
Color sourceColor = baseSourceColor;
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
}
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Use AnimatedContainer for smoother transitions instead of rebuilding
return Container(
decoration: BoxDecoration(
color: navBgColor,
@@ -554,7 +549,50 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
]
: null,
),
child: navBar, // Reuse pre-built navigation bar
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: const Icon(Icons.library_music_outlined),
activeIcon: const Icon(Icons.library_music_rounded),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
);
},
);
+82 -68
View File
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
/// A scrollbar that shows a letter popup when dragging, for fast navigation
/// through alphabetically sorted lists.
///
/// Scrolls PROPORTIONALLY (like a normal scrollbar) and shows which letter
/// section you're currently in via a popup indicator.
class LetterScrollbar extends StatefulWidget {
/// The scrollable child widget (ListView, GridView, etc.)
final Widget child;
@@ -9,16 +12,34 @@ class LetterScrollbar extends StatefulWidget {
/// The scroll controller for the child
final ScrollController controller;
/// List of items to extract letters from (must be sorted alphabetically)
/// Each item's first character is used to determine the letter
/// List of strings to extract letters from (should match visual sort order).
/// Each item's first character is used to determine the letter shown in the popup.
final List<String> items;
/// Callback when user taps/drags to a specific index
/// Callback when user taps/drags to a specific index.
final void Function(int index)? onScrollToIndex;
/// Callback when drag state changes (for disabling scroll-to-hide)
final void Function(bool isDragging)? onDragStateChanged;
/// Fixed item height for lists. Enables precise scroll calculation.
final double? itemExtent;
/// Number of columns for grid layouts.
final int? crossAxisCount;
/// Aspect ratio of grid children (width / height).
final double? childAspectRatio;
/// Spacing between grid rows (mainAxisSpacing)
final double? mainAxisSpacing;
/// Horizontal padding of the scrollable area
final double? horizontalPadding;
/// Bottom padding to prevent scrollbar from going behind bottom nav/mini player
final double bottomPadding;
const LetterScrollbar({
super.key,
required this.child,
@@ -26,6 +47,12 @@ class LetterScrollbar extends StatefulWidget {
required this.items,
this.onScrollToIndex,
this.onDragStateChanged,
this.itemExtent,
this.crossAxisCount,
this.childAspectRatio,
this.mainAxisSpacing,
this.horizontalPadding,
this.bottomPadding = 0,
});
@override
@@ -55,87 +82,54 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
final position = widget.controller.position;
if (position.maxScrollExtent > 0) {
final newFraction = (position.pixels / position.maxScrollExtent).clamp(0.0, 1.0);
// PERF: Only rebuild if fraction changed by more than 1% to reduce rebuilds
if ((newFraction - _scrollFraction).abs() > 0.01) {
setState(() {
_scrollFraction = newFraction;
_currentLetter = _getLetterAtFraction(newFraction);
});
}
}
}
}
// Build a map of letter -> first index for that letter
Map<String, int> get _letterIndexMap {
final map = <String, int>{};
for (int i = 0; i < widget.items.length; i++) {
final item = widget.items[i];
if (item.isNotEmpty) {
final letter = item[0].toUpperCase();
if (!map.containsKey(letter)) {
map[letter] = i;
}
}
/// Normalizes a character to a letter for the scrollbar.
String _normalizeToLetter(String char) {
if (char.isEmpty) return '';
final upper = char[0].toUpperCase();
if (upper.codeUnitAt(0) >= 65 && upper.codeUnitAt(0) <= 90) {
return upper;
}
return map;
return '#';
}
String _getLetterAtPosition(double position, double maxHeight) {
if (widget.items.isEmpty || maxHeight <= 0) return '';
/// Get the letter at a given scroll fraction (0.0 to 1.0)
String _getLetterAtFraction(double fraction) {
if (widget.items.isEmpty) return '';
// Calculate which item index corresponds to this position
final fraction = (position / maxHeight).clamp(0.0, 1.0);
final index = (fraction * (widget.items.length - 1)).round();
if (index >= 0 && index < widget.items.length) {
final index = (fraction * (widget.items.length - 1)).round().clamp(0, widget.items.length - 1);
if (index < widget.items.length) {
final item = widget.items[index];
if (item.isNotEmpty) {
return item[0].toUpperCase();
return _normalizeToLetter(item[0]);
}
}
return '';
}
void _scrollToLetter(String letter) {
final letterMap = _letterIndexMap;
if (letterMap.containsKey(letter)) {
final index = letterMap[letter]!;
if (widget.onScrollToIndex != null) {
widget.onScrollToIndex!(index);
} else {
// Estimate scroll position based on index
// This is approximate - works better with fixed height items
final scrollController = widget.controller;
if (scrollController.hasClients) {
final maxScroll = scrollController.position.maxScrollExtent;
final fraction = index / widget.items.length;
final targetScroll = (fraction * maxScroll).clamp(0.0, maxScroll);
// Update scroll fraction immediately for smooth thumb tracking
setState(() {
_scrollFraction = fraction;
});
scrollController.jumpTo(targetScroll);
}
}
}
}
void _handleDragStart(DragStartDetails details) {
setState(() {
_isDragging = true;
_dragPosition = details.localPosition.dy;
});
widget.onDragStateChanged?.call(true);
_updateLetterAndScroll(details.localPosition.dy);
_scrollToPosition(details.localPosition.dy);
}
void _handleDragUpdate(DragUpdateDetails details) {
setState(() {
_dragPosition = details.localPosition.dy;
});
_updateLetterAndScroll(details.localPosition.dy);
_scrollToPosition(details.localPosition.dy);
}
void _handleDragEnd(DragEndDetails details) {
@@ -145,18 +139,35 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
widget.onDragStateChanged?.call(false);
}
void _updateLetterAndScroll(double position) {
/// Scroll proportionally based on drag position
void _scrollToPosition(double position) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null) {
final height = renderBox.size.height;
final letter = _getLetterAtPosition(position, height);
if (letter.isNotEmpty && letter != _currentLetter) {
setState(() {
_currentLetter = letter;
});
_scrollToLetter(letter);
if (renderBox == null || !widget.controller.hasClients) return;
// Account for bottom padding in the effective track height
final totalHeight = renderBox.size.height;
final effectiveHeight = totalHeight - widget.bottomPadding;
if (effectiveHeight <= 0) return;
// Calculate scroll fraction from position (clamped to effective area)
final clampedPosition = position.clamp(0.0, effectiveHeight);
final fraction = clampedPosition / effectiveHeight;
// Get letter for display
final letter = _getLetterAtFraction(fraction);
// Scroll proportionally
final maxScroll = widget.controller.position.maxScrollExtent;
final targetScroll = (fraction * maxScroll).clamp(0.0, maxScroll);
setState(() {
_scrollFraction = fraction;
if (letter.isNotEmpty) {
_currentLetter = letter;
}
}
});
widget.controller.jumpTo(targetScroll);
}
@override
@@ -168,12 +179,12 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
// The scrollable content
widget.child,
// The draggable scrollbar area (right edge) - wider grab area
// The draggable scrollbar area (right edge)
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 32, // Wider grab area
bottom: widget.bottomPadding,
width: 32,
child: GestureDetector(
onVerticalDragStart: _handleDragStart,
onVerticalDragUpdate: _handleDragUpdate,
@@ -196,11 +207,11 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
final trackHeight = constraints.maxHeight;
final thumbHeight = _isDragging ? 60.0 : 40.0;
final availableTrack = trackHeight - thumbHeight;
final thumbTop = availableTrack * _scrollFraction;
final thumbTop = (availableTrack * _scrollFraction).clamp(0.0, availableTrack);
return Stack(
children: [
// Thumb that tracks scroll position - use AnimatedPositioned for smooth motion
// Thumb that tracks scroll position
AnimatedPositioned(
duration: const Duration(milliseconds: 50),
curve: Curves.linear,
@@ -229,7 +240,10 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
if (_isDragging && _currentLetter.isNotEmpty)
Positioned(
right: 48,
top: _dragPosition - 28,
top: (_dragPosition - 28).clamp(
0.0,
(context.findRenderObject() as RenderBox?)?.size.height ?? 500 - 56 - widget.bottomPadding,
),
child: Material(
elevation: 4,
color: colorScheme.primaryContainer,
+2 -2
View File
@@ -8,8 +8,8 @@ class MiniPlayerLayout {
static const double textLeftOffset = 10.0; // Gap between art and text
static const double textLeft = artSize + textLeftOffset; // 82px
// 3-line layout: track, artist, player
static const double primaryTop = 5.0; // raised slightly from 7
static const double secondaryTop = 26.0; // raised by 1
static const double primaryTop = 9.0;
static const double secondaryTop = 26.0;
static const double tertiaryTop = 46.0;
// 2-line layout (evenly spaced): player name, hint
// Height 72 / 3 = 24px spacing. Line 1 center at 24, Line 2 center at 48
+173 -26
View File
@@ -1,9 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
import '../../l10n/app_localizations.dart';
import '../../services/debug_logger.dart';
import '../../services/settings_service.dart';
final _volumeLogger = DebugLogger();
/// A player card that matches the mini player's visual style.
/// Used in the player reveal overlay when swiping down or tapping the device button.
@@ -23,10 +28,23 @@ class PlayerCard extends StatefulWidget {
final VoidCallback? onSkipNext;
final VoidCallback? onPower;
final ValueChanged<double>? onVolumeChange;
final ValueChanged<bool>? onPrecisionModeChanged;
// Pastel yellow for grouped players
static const Color groupBorderColor = Color(0xFFFFF59D);
// Precision mode settings
static const int precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
static const double precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
static const double precisionSensitivity = 0.1; // 10x more precise (full swipe = 10% change)
// PERF Phase 4: Pre-cached BoxShadow to avoid allocation per frame
static const BoxShadow cardShadow = BoxShadow(
color: Color(0x33000000), // 20% black
blurRadius: 8,
offset: Offset(0, 2),
);
const PlayerCard({
super.key,
required this.player,
@@ -43,6 +61,7 @@ class PlayerCard extends StatefulWidget {
this.onSkipNext,
this.onPower,
this.onVolumeChange,
this.onPrecisionModeChanged,
});
@override
@@ -54,7 +73,70 @@ class _PlayerCardState extends State<PlayerCard> {
double _dragVolumeLevel = 0.0;
double _cardWidth = 0.0;
int _lastVolumeUpdateTime = 0;
int _lastDragEndTime = 0; // Track when last drag ended for consecutive swipes
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
static const int _precisionThrottleMs = 50; // Faster updates in precision mode
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
// Precision mode state
bool _inPrecisionMode = false;
Timer? _precisionTimer;
Offset? _lastDragPosition;
double _lastLocalX = 0.0; // Last local X position during drag (for precision mode)
bool _precisionModeEnabled = true; // From settings
double _precisionZoomCenter = 0.0; // Volume level when precision mode started
double _precisionStartX = 0.0; // Finger X position when precision mode started
@override
void initState() {
super.initState();
_loadPrecisionModeSetting();
}
Future<void> _loadPrecisionModeSetting() async {
final enabled = await SettingsService.getVolumePrecisionMode();
if (mounted) {
setState(() {
_precisionModeEnabled = enabled;
});
}
}
@override
void dispose() {
_precisionTimer?.cancel();
super.dispose();
}
void _enterPrecisionMode() {
if (_inPrecisionMode) return;
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
setState(() {
_inPrecisionMode = true;
_precisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
_precisionStartX = _lastLocalX; // Capture finger position at entry
});
widget.onPrecisionModeChanged?.call(true);
_volumeLogger.debug(
'PRECISION_MODE [${widget.player.name}]: ENTERED at ${(_precisionZoomCenter * 100).round()}%',
context: 'Volume',
);
}
void _exitPrecisionMode() {
_precisionTimer?.cancel();
_precisionTimer = null;
if (!_inPrecisionMode) return;
setState(() {
_inPrecisionMode = false;
});
widget.onPrecisionModeChanged?.call(false);
_volumeLogger.debug(
'PRECISION_MODE [${widget.player.name}]: EXITED',
context: 'Volume',
);
}
@override
Widget build(BuildContext context) {
@@ -67,7 +149,10 @@ class _PlayerCardState extends State<PlayerCard> {
final filledColor = widget.backgroundColor;
final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
return LayoutBuilder(
// RepaintBoundary isolates this card's repaint from parent transform animations
// This improves performance during the staggered reveal animation
return RepaintBoundary(
child: LayoutBuilder(
builder: (context, constraints) {
_cardWidth = constraints.maxWidth;
@@ -92,13 +177,8 @@ class _PlayerCardState extends State<PlayerCard> {
decoration: BoxDecoration(
color: unfilledColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
// PERF Phase 4: Use pre-cached static BoxShadow
boxShadow: const [PlayerCard.cardShadow],
),
clipBehavior: Clip.antiAlias,
child: Align(
@@ -133,6 +213,7 @@ class _PlayerCardState extends State<PlayerCard> {
),
);
},
),
);
}
@@ -142,13 +223,8 @@ class _PlayerCardState extends State<PlayerCard> {
decoration: BoxDecoration(
color: widget.backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
// PERF Phase 4: Use pre-cached static BoxShadow
boxShadow: const [PlayerCard.cardShadow],
),
clipBehavior: Clip.antiAlias,
child: Row(
@@ -282,25 +358,83 @@ class _PlayerCardState extends State<PlayerCard> {
}
void _onDragStart(DragStartDetails details) {
// Get current volume from player
final currentVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
// For consecutive swipes, use local volume (API may not have updated player state yet)
final now = DateTime.now().millisecondsSinceEpoch;
final timeSinceLastDrag = now - _lastDragEndTime;
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
// Use local volume if:
// 1. We have a local override (we've swiped before) AND
// 2. We're within the consecutive swipe window (5 seconds)
// Otherwise, read fresh from player state
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
final playerVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
final startVolume = useLocalVolume
? _dragVolumeLevel // Continue from where last swipe ended
: playerVolume; // Fresh from player
_volumeLogger.debug(
'DRAG_START [${widget.player.name}]: '
'useLocal=$useLocalVolume, hasOverride=$_hasLocalVolumeOverride, '
'inWindow=$isWithinWindow (${timeSinceLastDrag}ms ago), '
'localVol=${(_dragVolumeLevel * 100).round()}%, '
'playerVol=${(playerVolume * 100).round()}%, '
'startVol=${(startVolume * 100).round()}%',
context: 'Volume',
);
setState(() {
_isDraggingVolume = true;
_dragVolumeLevel = currentVolume;
_dragVolumeLevel = startVolume;
});
_lastDragPosition = details.globalPosition;
HapticFeedback.lightImpact();
}
void _onDragUpdate(DragUpdateDetails details) {
if (!_isDraggingVolume || _cardWidth <= 0) return;
// Directional volume: drag right = increase, drag left = decrease
// Distance determines amount of change (full card width = 100%)
// Multiple swipes accumulate - each starts from current volume
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / _cardWidth;
// Track local X position for precision mode
_lastLocalX = details.localPosition.dx;
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
// Check for stillness to trigger precision mode (only if enabled in settings)
final currentPosition = details.globalPosition;
if (_precisionModeEnabled && _lastDragPosition != null) {
final movement = (currentPosition - _lastDragPosition!).distance;
if (movement < PlayerCard.precisionStillnessThreshold) {
// Finger is still - start precision timer if not already running
if (_precisionTimer == null && !_inPrecisionMode) {
_precisionTimer = Timer(
Duration(milliseconds: PlayerCard.precisionTriggerMs),
_enterPrecisionMode,
);
}
} else {
// Finger moved - cancel timer (but don't exit precision mode if already in it)
_precisionTimer?.cancel();
_precisionTimer = null;
}
}
_lastDragPosition = currentPosition;
double newVolume;
if (_inPrecisionMode) {
// PRECISION MODE: Movement from entry point maps to zoomed range
// Full card width of movement = precisionSensitivity (10%) change
// e.g., at 40% center: moving full card right = 50%, full card left = 30%
final offsetX = details.localPosition.dx - _precisionStartX;
final normalizedOffset = offsetX / _cardWidth; // -1.0 to +1.0 range
final volumeChange = normalizedOffset * PlayerCard.precisionSensitivity;
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
} else {
// NORMAL MODE: Delta-based movement (full card width = 100%)
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / _cardWidth;
newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
}
// Always update visual
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
@@ -308,9 +442,10 @@ class _PlayerCardState extends State<PlayerCard> {
_dragVolumeLevel = newVolume;
});
// Throttle API calls to prevent flooding
// Throttle API calls to prevent flooding (faster in precision mode)
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastVolumeUpdateTime >= _volumeThrottleMs) {
final throttleMs = _inPrecisionMode ? _precisionThrottleMs : _volumeThrottleMs;
if (now - _lastVolumeUpdateTime >= throttleMs) {
_lastVolumeUpdateTime = now;
widget.onVolumeChange?.call(newVolume);
}
@@ -319,7 +454,17 @@ class _PlayerCardState extends State<PlayerCard> {
void _onDragEnd(DragEndDetails details) {
// Send final volume on release
_volumeLogger.debug(
'DRAG_END [${widget.player.name}]: '
'finalVol=${(_dragVolumeLevel * 100).round()}%, '
'precisionMode=$_inPrecisionMode',
context: 'Volume',
);
widget.onVolumeChange?.call(_dragVolumeLevel);
_lastDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
_exitPrecisionMode();
_lastDragPosition = null;
setState(() {
_isDraggingVolume = false;
});
@@ -327,6 +472,8 @@ class _PlayerCardState extends State<PlayerCard> {
}
void _onDragCancel() {
_exitPrecisionMode();
_lastDragPosition = null;
setState(() {
_isDraggingVolume = false;
});
+131 -96
View File
@@ -6,7 +6,6 @@ import '../../l10n/app_localizations.dart';
import '../../providers/music_assistant_provider.dart';
import '../../services/settings_service.dart';
import '../../theme/design_tokens.dart';
import '../../theme/theme_provider.dart';
import '../../theme/palette_helper.dart';
import '../../constants/timings.dart';
import '../global_player_overlay.dart';
@@ -41,9 +40,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
Timer? _refreshTimer;
// Track vertical drag for dismissal
double _dragOffset = 0;
bool _isDragging = false;
// Threshold for drag-to-dismiss (in animation value, 0.0-1.0)
static const double _dismissThreshold = 0.7;
// Scroll controller for when player list overflows
final ScrollController _scrollController = ScrollController();
bool _isScrollable = false;
@@ -54,6 +55,13 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Hint system
bool _showHints = true;
// PERF: Pre-cached static BoxShadow to avoid allocation per frame
static const BoxShadow _cardShadow = BoxShadow(
color: Color(0x33000000), // 20% black
blurRadius: 8,
offset: Offset(0, 2),
);
@override
void initState() {
super.initState();
@@ -147,13 +155,24 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Trigger single bounce on mini player when cards are ~70% collapsed
// This creates the effect of cards "landing" on the mini player
bool bounceFired = false;
void checkBounce() {
if (!bounceFired && _revealController.value < 0.3) {
void fireBounce() {
if (!bounceFired) {
bounceFired = true;
GlobalPlayerOverlay.triggerBounce();
}
}
void checkBounce() {
if (_revealController.value < 0.3) {
fireBounce();
}
}
// If user already dragged past bounce threshold, fire immediately
if (_revealController.value < 0.3) {
fireBounce();
}
_revealController.duration = const Duration(milliseconds: 150);
_revealController.addListener(checkBounce);
_revealController.reverse().then((_) {
@@ -194,11 +213,17 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Ignore swipe up when not scrollable - cards should stay fixed
if (delta <= 0) return;
// Swiping down = dismiss gesture
setState(() {
_isDragging = true;
_dragOffset += delta;
});
// Swiping down = dismiss gesture - directly control animation controller
_isDragging = true;
// Convert drag delta to animation value change
// Use average card travel distance (~150px) as reference
const dragSensitivity = 150.0;
final valueDelta = delta / dragSensitivity;
// Decrease animation value (cards move down)
final newValue = (_revealController.value - valueDelta).clamp(0.0, 1.0);
_revealController.value = newValue;
}
void _handleVerticalDragEnd(DragEndDetails details) {
@@ -206,31 +231,27 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// If not dragging (was scrolling), don't dismiss
if (!_isDragging) return;
_isDragging = false;
// Dismiss if dragged down enough or with enough velocity
if (_dragOffset > 100 || velocity > 500) {
// Dismiss if dragged past threshold or with enough velocity
if (_revealController.value < _dismissThreshold || velocity > 500) {
HapticFeedback.lightImpact();
// Reset drag offset before dismiss so animation starts from correct position
setState(() {
_isDragging = false;
_dragOffset = 0;
});
dismiss();
} else {
// Spring back
setState(() {
_isDragging = false;
_dragOffset = 0;
});
// Spring back to fully revealed
_revealController.duration = const Duration(milliseconds: 150);
_revealController.forward();
}
}
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// PERF Phase 4: Pre-compute hint colors outside animation loop
final hintColor = colorScheme.onSurface.withOpacity(0.7);
// Back gesture is handled at GlobalPlayerOverlay level
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
@@ -267,6 +288,31 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
}
});
// PERF Phase 4: Pre-compute player card data OUTSIDE AnimatedBuilder
// This avoids re-gathering player data every animation frame
final playerDataList = players.map((player) {
final isPlaying = player.state == 'playing';
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
final isGrouped = maProvider.isPlayerManuallySynced(player.playerId);
return _PlayerCardData(
player: player,
playerTrack: playerTrack,
albumArtUrl: albumArtUrl,
isPlaying: isPlaying,
isGrouped: isGrouped,
cardBgColor: cardBgColor,
cardTextColor: cardTextColor,
);
}).toList();
return AnimatedBuilder(
animation: _revealAnimation,
builder: (context, child) {
@@ -287,7 +333,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
Positioned(
left: 12,
right: 12,
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8,
child: GestureDetector(
onVerticalDragUpdate: _handleVerticalDragUpdate,
onVerticalDragEnd: _handleVerticalDragEnd,
@@ -308,6 +354,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Show different hint for onboarding vs regular use
final isOnboarding = widget.showOnboardingHints;
// PERF Phase 4: Use pre-computed hintColor
Widget buildHintRow(IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
@@ -315,13 +362,13 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
Icon(
icon,
size: 18,
color: colorScheme.onSurface.withOpacity(0.7),
color: hintColor,
),
const SizedBox(width: 6),
Text(
text,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
color: hintColor,
fontSize: 16,
fontWeight: FontWeight.w500,
),
@@ -354,31 +401,23 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Build player cards - scrollable when overflow, otherwise static
// Using ListView.builder for many players: only builds visible items
// This significantly improves animation performance with 10+ players
// PERF Phase 4: Use pre-computed playerDataList
if (needsScroll)
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxListHeight),
child: ListView.builder(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
itemCount: players.length,
itemCount: playerDataList.length,
// Fixed height per item for optimal scroll performance
itemExtent: cardHeight + cardSpacing,
itemBuilder: (context, index) {
final player = players[index];
final isPlaying = player.state == 'playing';
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
final data = playerDataList[index];
// Animation: slide from behind mini player
// Only calculate for items that will be built (visible ones)
const baseOffset = 80.0;
final reverseIndex = players.length - 1 - index;
final reverseIndex = playerDataList.length - 1 - index;
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
final slideOffset = distanceToTravel * (1.0 - t);
@@ -387,33 +426,33 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
child: Padding(
padding: const EdgeInsets.only(bottom: cardSpacing),
child: PlayerCard(
player: player,
trackInfo: playerTrack,
albumArtUrl: albumArtUrl,
player: data.player,
trackInfo: data.playerTrack,
albumArtUrl: data.albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
backgroundColor: cardBgColor,
textColor: cardTextColor,
isPlaying: data.isPlaying,
isGrouped: data.isGrouped,
backgroundColor: data.cardBgColor,
textColor: data.cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(player);
maProvider.selectPlayer(data.player);
dismiss();
},
onLongPress: () {
HapticFeedback.mediumImpact();
maProvider.togglePlayerSync(player.playerId);
maProvider.togglePlayerSync(data.player.playerId);
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
if (data.isPlaying) {
maProvider.pausePlayer(data.player.playerId);
} else {
maProvider.resumePlayer(player.playerId);
maProvider.resumePlayer(data.player.playerId);
}
},
onSkipNext: () => maProvider.nextTrack(player.playerId),
onPower: () => maProvider.togglePower(player.playerId),
onVolumeChange: (volume) => maProvider.setVolume(player.playerId, (volume * 100).round()),
onSkipNext: () => maProvider.nextTrack(data.player.playerId),
onPower: () => maProvider.togglePower(data.player.playerId),
onVolumeChange: (volume) => maProvider.setVolume(data.player.playerId, (volume * 100).round()),
),
),
);
@@ -422,28 +461,14 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
)
else
// Non-scrollable list for when players fit
...List.generate(players.length, (index) {
final player = players[index];
final isPlaying = player.state == 'playing';
// Get track info for this player
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
// Get album art URL - use track directly like mini player does
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
// Use per-player colors if available, otherwise use defaults
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
// PERF Phase 4: Use pre-computed playerDataList
...List.generate(playerDataList.length, (index) {
final data = playerDataList[index];
// Animation: all cards start hidden behind mini player
// and fan out to their final positions with spring physics
const baseOffset = 80.0;
final reverseIndex = players.length - 1 - index;
final reverseIndex = playerDataList.length - 1 - index;
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
// Use the elastic animation value directly (already has bounce)
@@ -454,44 +479,33 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PlayerCard(
player: player,
trackInfo: playerTrack,
albumArtUrl: albumArtUrl,
player: data.player,
trackInfo: data.playerTrack,
albumArtUrl: data.albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
backgroundColor: cardBgColor,
textColor: cardTextColor,
isPlaying: data.isPlaying,
isGrouped: data.isGrouped,
backgroundColor: data.cardBgColor,
textColor: data.cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(player);
maProvider.selectPlayer(data.player);
dismiss();
},
onLongPress: () {
HapticFeedback.mediumImpact();
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Syncing ${player.name}...'),
duration: const Duration(seconds: 2),
),
);
maProvider.togglePlayerSync(player.playerId);
maProvider.togglePlayerSync(data.player.playerId);
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
if (data.isPlaying) {
maProvider.pausePlayer(data.player.playerId);
} else {
maProvider.resumePlayer(player.playerId);
maProvider.resumePlayer(data.player.playerId);
}
},
onSkipNext: () {
maProvider.nextTrack(player.playerId);
},
onPower: () {
maProvider.togglePower(player.playerId);
},
onVolumeChange: (volume) => maProvider.setVolume(player.playerId, (volume * 100).round()),
onSkipNext: () => maProvider.nextTrack(data.player.playerId),
onPower: () => maProvider.togglePower(data.player.playerId),
onVolumeChange: (volume) => maProvider.setVolume(data.player.playerId, (volume * 100).round()),
),
),
);
@@ -508,3 +522,24 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
);
}
}
/// PERF Phase 4: Pre-computed player card data to avoid gathering per animation frame
class _PlayerCardData {
final dynamic player;
final dynamic playerTrack;
final String? albumArtUrl;
final bool isPlaying;
final bool isGrouped;
final Color cardBgColor;
final Color cardTextColor;
const _PlayerCardData({
required this.player,
required this.playerTrack,
required this.albumArtUrl,
required this.isPlaying,
required this.isGrouped,
required this.cardBgColor,
required this.cardTextColor,
});
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -212,6 +212,7 @@ Future<void> showPlayerPickerSheet({
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
useRootNavigator: true, // Required for overlays like QueuePanel
builder: (context) => PlayerPickerSheet(
title: title,
players: players,
+1 -3
View File
@@ -245,14 +245,12 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
// Show snackbar for visual feedback
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Syncing ${player.name}...'),
content: Text(S.of(context)!.syncingPlayer(player.name)),
duration: const Duration(seconds: 2),
),
);
// Long-press to sync/unsync player
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
debugPrint('🔗 Player isGrouped: ${player.isGrouped}, groupMembers: ${player.groupMembers}');
maProvider.togglePlayerSync(player.playerId);
},
borderRadius: BorderRadius.circular(16),

Some files were not shown because too many files have changed in this diff Show More