Compare commits

...
74 Commits
Author SHA1 Message Date
ChrisandClaude Opus 4.5 c00fb3dc78 chore: Bump version to 2.9.1-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:27:03 +00:00
ChrisandClaude Opus 4.5 ff34479169 feat: Provider icons and library performance optimizations
Provider Icons:
- Add provider icon overlays to series detail screen (grid/list)
- Add provider icon overlays to library playlists (grid/list)
- Add ProviderIconOverlay widget and provider manifest model

Performance Optimizations:
- Fix empty setState in SyncService listener - only rebuild on sync complete
- Add cached sorting for albums, artists, authors (avoid O(n log n) per rebuild)
- Add cached track sort keys for LetterScrollbar
- Fix image cache size mismatches (author 128px, audiobook list 128px, series 150px)
- Simplify provider toggle setState to avoid double rebuild
- Remove redundant text Hero animations from card widgets
- Defer color extraction from card tap to detail screen initState
- Increase fallback image debounce from 200ms to 400ms

Other Changes:
- Update screenshots
- Add new localizations
- Various detail screen improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:24:37 +00:00
ChrisandClaude Opus 4.5 d7127ea7da chore: Bump version to 2.9.0-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:16:35 +00:00
ChrisandClaude Opus 4.5 29b4887d0d docs: Add player selector implementation plan
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:04:58 +00:00
ChrisandClaude Opus 4.5 18603c34e1 Merge audit-fixes: Memory leaks, performance, and bug fixes
Phase 1 - Critical Memory Leaks & Safety:
- Fix pending API requests memory leak (max 100, cancel on reconnect)
- Fix ImageStream listener leaks in expandable player
- Add database batch operations (10x faster sync)
- Fix connection guard race condition
- Mark undersized touch targets as deprecated (WCAG)
- Extract SleepTimerProvider and ConnectionProvider skeletons

Phase 2 - UI/Widget Fixes:
- Add LRU cache eviction to MetadataService (bounded caches)
- Add staleness detection for async player state updates
- Add explicit animation controller listener cleanup

Phase 3 - Medium Priority:
- Add MediaItem equality operators (itemId + provider)
- Extract duration formatting utility (remove 3 duplicates)

Bug Fixes:
- Fix volume precision toggle not affecting player selector overlay

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 08:11:53 +00:00
ChrisandClaude Opus 4.5 5ed2bf7725 Fix volume precision toggle not affecting player selector overlay
The ExpandablePlayer was loading the volume precision setting only once
in initState(), so changes made in settings weren't reflected until app
restart. PlayerCards in the overlay were loading fresh values on each
creation.

Fix: Add didUpdateWidget to reload the precision mode setting when the
device reveal overlay becomes visible. This ensures the setting is fresh
after the user may have changed it in settings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:44:13 +00:00
ChrisandClaude Opus 4.5 67a99c88a0 Phase 3: Medium priority fixes - MediaItem equality and duration formatting
MediaItem Equality (3.4):
- Add == operator based on itemId and provider
- Add hashCode using Object.hash for correct Set/Map behavior
- Enables proper comparison in collections and deduplication

Duration Formatter Utility (3.5):
- Create lib/utils/duration_formatter.dart with reusable functions
- Add DurationFormatter extension on Duration
- Add formatDurationSeconds() and formatDurationOrEmpty() functions
- Replace 3 duplicate _formatDuration implementations:
  - expandable_player.dart
  - chapters_panel.dart
  - queue_panel.dart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:21:29 +00:00
ChrisandClaude Opus 4.5 ffbb41580b Phase 2: UI/Widget fixes and memory management
MetadataService Cache Eviction (2.4):
- Create LruCache<K,V> utility class with O(1) LRU operations
- Create NullableLruCache for caches storing null (failed lookups)
- Replace unbounded static maps with bounded LRU caches:
  - Description cache: 100 entries
  - Artist/album image caches: 200 entries each
  - Author image cache: 100 entries
  - Lyrics cache: 500 entries with manual eviction

Async Cancellation on Player Switch (2.2):
- Add staleness detection to _updatePlayerState()
- Capture player ID at operation start
- Check for player changes after each await
- Abort stale operations to prevent UI inconsistency

Scroll Controller Cleanup (2.3):
- Verified existing cleanup is correct
- Only tracks scroll controller has listener, properly removed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:17:53 +00:00
ChrisandClaude Opus 4.5 6942b56cfb Phase 1: Critical memory leaks, safety & performance fixes
Memory Leak Fixes:
- Add max pending requests limit (100) to prevent unbounded growth
- Cancel pending requests in _reconnect() before reconnecting
- Track ImageStream listeners in expandable_player for proper cleanup
- Add cleanup in dispose() for active image stream listeners

Database Performance:
- Add batchCacheItems() method using drift batch API
- Chunk batches at 500 items to avoid SQLite limits
- Refactor sync_service to use batch inserts (~10x faster)
- Add BatchCacheItem data class for type-safe batch operations

Race Condition Fix:
- Make connection guard check-and-set atomic
- Create completer before check to close race window

WCAG Accessibility:
- Mark ButtonSizes.sm/md as @Deprecated (below 44x44 minimum)
- Add ButtonSizes.minAccessible constant (48x48)
- Add clear documentation on WCAG Level AA/AAA requirements

Provider Decomposition (partial):
- Extract SleepTimerProvider with callback pattern
- Extract ConnectionProvider skeleton for future integration
- Prepare facade pattern for backward compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:13:40 +00:00
ChrisandClaude Opus 4.5 14ba524ed0 refactor: Replace Play On popups with Player Selector overlay
Use the existing PlayerRevealOverlay for "Play On" actions instead of
the PlayerPickerSheet popup modal. This provides a more consistent UX
by reusing the same player selection interface throughout the app.

- Add onPlayerSelected callback and contextHint to PlayerRevealOverlay
- Add showPlayerSelectorForAction helper to GlobalPlayerOverlay
- Refactor album, artist, playlist, podcast, audiobook, and search screens
- Add localization strings for context-specific hints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 06:45:57 +00:00
ChrisandClaude Opus 4.5 49fc2a77e7 fix: Multiple notification and UI fixes
- Clear notification when switching to idle player (fixes issue #42)
- Fix notification progress bar desync after seek and with Sendspin
  - Always use position tracker instead of PCM elapsed time
  - PCM elapsed resets to 0 on stream start, causing wrong position
- Fix invisible nav bar due to stale playerExpansionNotifier
  - Reset notifier when player hidden (disconnect/no player)
  - Reset notifier after collapse() completes even if already at 0
- Fix welcome backdrop showing dark color in light theme
  - Now uses theme-appropriate surface color

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 05:26:33 +00:00
ChrisandClaude Opus 4.5 6632fa14c6 feat: Add sleep timer and fix progress bar freeze bug
Sleep Timer:
- Add sleep timer panel with preset durations (15/30/45/60 min)
- Support finish current track option
- Show countdown in queue panel header
- Auto-stop playback when timer expires

Progress Bar Fix:
- Fix anchor refresh logic when server sends stale position data
- Allow anchor refresh even with suspicious reset when anchor is stale
- Use interpolated position to prevent backward jumps from bad server data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 04:47:31 +00:00
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
95 changed files with 23271 additions and 4501 deletions
+216 -95
View File
@@ -1,136 +1,257 @@
# Ensemble Flutter App - Code Audit Report # Ensemble Codebase Audit Report
**Date:** January 4, 2026 **Date**: 2026-01-14
**Branch:** `audit/code-quality-review` **Scope**: Full codebase audit (104 Dart files, 18 screens)
**Files Audited:** 93 Dart files **Focus Areas**: UI Continuity, Performance, Logic/Best Practices
--- ---
## Executive Summary ## 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 | | Severity | Count | Key Themes |
|----------|-------------|----------| |----------|-------|------------|
| Deprecated APIs | 20 instances | HIGH | | **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
| Missing const constructors | 100+ instances | MEDIUM | | **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
| Image cache parameters | 7 locations | MEDIUM | | **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
| Code duplication | 8 view mode functions | LOW | | **Low** | 15+ | Minor polish items |
| Color constants | 3 files with duplicates | LOW |
**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) ### 1. God Object: MusicAssistantProvider (5,443 lines)
Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`. **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:** ### 2. God Object: NewLibraryScreen (4,209 lines)
- lib/main.dart:335, 336 **File**: `lib/screens/new_library_screen.dart`
- lib/screens/library_artists_screen.dart:21 - 10 scroll controllers
- lib/screens/new_home_screen.dart:121 - 15+ view mode states
- lib/screens/artist_details_screen.dart:708, 720 - 15+ sort order states
- lib/screens/library_playlists_screen.dart:53 - Handles: Music, Books, Podcasts, Radio tabs
- lib/screens/library_tracks_screen.dart:19 - **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
- lib/screens/login_screen.dart:489
- lib/screens/new_library_screen.dart:933, 957, 1020, 1021 ### 3. Sequential Blocking Startup (500-1500ms wasted)
- lib/screens/album_details_screen.dart:807, 819 **File**: `lib/main.dart:33-66`
- lib/screens/library_albums_screen.dart:19 ```dart
- lib/screens/search_screen.dart:427, 738, 739 await DatabaseService.instance.initialize();
- lib/screens/settings_screen.dart:245 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) ### Performance
High-impact optimization - used extensively in button padding. | 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:** ### Architecture
- lib/screens/album_details_screen.dart (12 instances) | Issue | File | Impact |
- lib/screens/artist_details_screen.dart (8 instances) |-------|------|--------|
- lib/screens/audiobook_detail_screen.dart (3 instances) | State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
- lib/screens/audiobook_series_screen.dart (2 instances) | 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
- lib/screens/audiobook_author_screen.dart (2 instances) | 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) ### UI/UX
Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`. | 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:** ### UI Consistency
- Line 1395-1402: Author image (48x48) - missing cache params - Border radius varies: 8, 12, 20, 22, 25 for same element types
- Line 1480-1486: Author card image - missing cache params - `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
- Line 1572-1580: Audiobook cover (56x56) - missing cache params - Hardcoded colors for status (Colors.red, Colors.green)
- Line 1740-1745: Audiobook grid cover - missing cache params - Inconsistent icon sizes across screens
- 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
**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) | # | Task | File(s) | Effort |
**File:** lib/screens/new_library_screen.dart |---|------|---------|--------|
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
Functions that follow identical switch/case pattern: | 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
- `_cycleArtistsViewMode()` (line 184) | 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
- `_cycleAlbumsViewMode()` (line 200) | 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
- `_cyclePlaylistsViewMode()` (line 216) | 5 | Add Flutter error boundary | main.dart | 30 min |
- `_cycleAuthorsViewMode()` (line 232) | 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
- `_cycleAudiobooksViewMode()` (line 248) | 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
- `_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
--- ---
## 5. POSITIVE FINDINGS (Already Optimized) ## Refactoring Roadmap
- **RepaintBoundary**: Properly implemented in all list item widgets ### Phase 1: Quick Wins (1-2 days)
- **ListView.builder**: Used consistently with cacheExtent: 1000 - Fix touch targets
- **ScrollController disposal**: All 8+ controllers properly disposed - Fix bottom spacing
- **Animation patterns**: Proper listener cleanup in expandable_player.dart - Add navigation debounce
- **Debounced operations**: Color extraction debounced to 300ms - 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) The codebase already demonstrates many good practices:
1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations)
2. Add `const` to EdgeInsets.zero instances
### Phase 2: Image Optimization - **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
1. Add memCacheWidth/memCacheHeight to CachedNetworkImage instances in new_library_screen.dart - **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
### Phase 3: Code Cleanup (Optional) - **Theme system**: Material You support, adaptive colors from album art
1. Extract view mode cycling to utility function - **Offline support**: Cache-first loading, graceful degradation
2. Centralize color constants - **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
--- ---
## Files by Complexity ## Files by Priority
| File | Lines | Status | | Priority | File | Lines | Key Issues |
|------|-------|--------| |----------|------|-------|------------|
| lib/screens/search_screen.dart | 3,489 | Good structure, minor fixes needed | | 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
| lib/screens/new_library_screen.dart | 3,351 | Good performance, some duplication | | 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
| lib/widgets/expandable_player.dart | 2,577 | Well-optimized animations | | 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.
+1
View File
@@ -11,6 +11,7 @@
android:label="Ensemble" android:label="Ensemble"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:appCategory="audio"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config">
<activity <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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="launch_background_color">#1a1a1a</color> <color name="launch_background_color">#1a1a1a</color>
</resources> <color name="ic_launcher_background">#1a1a1a</color>
</resources>
+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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 649 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 KiB

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 909 KiB

+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
+221
View File
@@ -0,0 +1,221 @@
# Feature: Replace "Play On" Popups with Player Selector
## Overview
Replace the `PlayerPickerSheet` popup modal with the existing Player Selector (reveal overlay) when users tap "Play on" buttons throughout the app. This provides a consistent, richer player selection experience.
## Current State
### PlayerPickerSheet Popup
Used in 7+ places:
- `album_details_screen.dart` - Play Album, Play From Here, Play Radio
- `playlist_details_screen.dart` - Play Playlist, Play Radio
- `search_screen.dart` - Play album/playlist/audiobook/radio from search
- `podcast_detail_screen.dart` - Play Episode
- `audiobook_detail_screen.dart` - Play Audiobook
- `queue_screen.dart` - Transfer Queue (special case - keep popup)
### Player Selector (Reveal Overlay)
- Location: `lib/widgets/player/player_reveal_overlay.dart`
- Shows vertical list of all players with artwork, current track, sync status
- Has hints above player list: "Long-press to sync" & "Swipe to adjust volume"
- Triggered by pull-down gesture or device button tap
## Proposed Changes
### 1. Modify PlayerRevealOverlay
**File:** `lib/widgets/player/player_reveal_overlay.dart`
Add new optional parameters:
```dart
class PlayerRevealOverlay extends StatefulWidget {
final VoidCallback onDismiss;
final double miniPlayerBottom;
final double miniPlayerHeight;
final bool showOnboardingHints;
// NEW: Callback when player is selected for a pending action
final void Function(Player player)? onPlayerSelected;
// NEW: Context hint to display instead of default hints
// e.g., "Select player to play album"
final String? contextHint;
// NEW: Icon for context hint (default: speaker icon)
final IconData? contextHintIcon;
```
### 2. Modify Hint Display
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 386-394)
```dart
// Current:
: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
const SizedBox(height: 4),
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
],
),
// New:
: widget.contextHint != null
? buildHintRow(
widget.contextHintIcon ?? Icons.play_circle_outline,
widget.contextHint!,
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
const SizedBox(height: 4),
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
],
),
```
### 3. Modify Player Card onTap
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 437-441 and 490-494)
```dart
// Current:
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(data.player);
dismiss();
},
// New:
onTap: () {
HapticFeedback.mediumImpact();
if (widget.onPlayerSelected != null) {
// Execute the pending action with selected player
widget.onPlayerSelected!(data.player);
} else {
// Default behavior: just select the player
maProvider.selectPlayer(data.player);
}
dismiss();
},
```
### 4. Add Helper Method to GlobalPlayerOverlay
**File:** `lib/widgets/global_player_overlay.dart`
Add a static method to show player selector with pending action:
```dart
/// Show player selector for a "Play on" action
///
/// [contextHint] - Message shown instead of default hints
/// [onPlayerSelected] - Called when user taps a player
///
/// Example:
/// ```dart
/// GlobalPlayerOverlay.showPlayerSelectorForAction(
/// contextHint: "Select player to play album",
/// onPlayerSelected: (player) {
/// provider.playAlbum(albumId, playerId: player.playerId);
/// },
/// );
/// ```
static void showPlayerSelectorForAction({
required String contextHint,
required void Function(Player player) onPlayerSelected,
IconData? hintIcon,
}) {
// Implementation: trigger the player reveal overlay with these parameters
// May need to store pending action in state and pass to overlay
}
```
### 5. Update Calling Sites
Replace `showPlayerPickerSheet()` calls with new method:
**Example - album_details_screen.dart:**
```dart
// Current (around line 1305-1316):
showPlayerPickerSheet(
context: context,
title: 'Play Album',
onPlayerSelected: (player) async {
GlobalPlayerOverlay.hidePlayer();
await maProvider.playAlbum(albumId, playerId: player.playerId);
GlobalPlayerOverlay.showPlayer();
},
);
// New:
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum, // Add localization
onPlayerSelected: (player) async {
await maProvider.playAlbum(albumId, playerId: player.playerId);
},
);
```
### 6. Add Localizations
**File:** `lib/l10n/app_en.arb` (and other language files)
```json
"selectPlayerToPlay": "Select player to play",
"selectPlayerToPlayAlbum": "Select player to play album",
"selectPlayerToPlayPlaylist": "Select player to play playlist",
"selectPlayerToPlayRadio": "Select player to start radio",
"selectPlayerToPlayEpisode": "Select player to play episode",
"selectPlayerToPlayAudiobook": "Select player to play audiobook"
```
## Files to Modify
1. `lib/widgets/player/player_reveal_overlay.dart` - Add parameters, modify hints & onTap
2. `lib/widgets/global_player_overlay.dart` - Add helper method
3. `lib/screens/album_details_screen.dart` - Replace 3 popup calls
4. `lib/screens/playlist_details_screen.dart` - Replace 2 popup calls
5. `lib/screens/search_screen.dart` - Replace ~5 popup calls
6. `lib/screens/podcast_detail_screen.dart` - Replace 1 popup call
7. `lib/screens/audiobook_detail_screen.dart` - Replace 1 popup call
8. `lib/l10n/app_en.arb` - Add new strings
9. `lib/l10n/app_de.arb`, `app_fr.arb`, `app_es.arb` - Translations
## Exception: Queue Transfer
**Keep the popup for `queue_screen.dart`** because:
- Needs to filter out current player (can't transfer to self)
- Needs to filter out certain player types
- Different UX intent (transfer vs play)
## Implementation Order
1. Modify `PlayerRevealOverlay` to accept new parameters
2. Add helper method to `GlobalPlayerOverlay`
3. Test with one screen (album_details_screen.dart)
4. Add localizations
5. Migrate remaining screens
6. Clean up unused code from PlayerPickerSheet if no longer needed elsewhere
## Testing Checklist
- [ ] Album details: Play Album button
- [ ] Album details: Play From Here (track context menu)
- [ ] Album details: Play Radio (track context menu)
- [ ] Playlist details: Play Playlist button
- [ ] Playlist details: Play Radio (track context menu)
- [ ] Search: Play album from results
- [ ] Search: Play playlist from results
- [ ] Search: Play radio from results
- [ ] Podcast detail: Play episode
- [ ] Audiobook detail: Play audiobook
- [ ] Queue transfer still works with popup
- [ ] Long-press to sync still works in player selector
- [ ] Dismiss by tapping backdrop works
- [ ] Dismiss by swiping down works
Binary file not shown.
Binary file not shown.
+3
View File
@@ -10,4 +10,7 @@ class NetworkConstants {
/// Default HTTP port /// Default HTTP port
static const int defaultHttpPort = 80; static const int defaultHttpPort = 80;
/// Maximum number of pending WebSocket requests to prevent memory leaks
static const int maxPendingRequests = 100;
} }
+3
View File
@@ -31,6 +31,9 @@ class Timings {
/// Search debounce delay /// Search debounce delay
static const Duration searchDebounce = Duration(milliseconds: 500); 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 /// Delay after track change before updating state
static const Duration trackChangeDelay = Duration(milliseconds: 500); static const Duration trackChangeDelay = Duration(milliseconds: 500);
} }
+115 -8
View File
@@ -78,6 +78,10 @@ class LibraryCache extends Table {
/// Whether this item was deleted on the server /// Whether this item was deleted on the server
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))(); 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 @override
Set<Column> get primaryKey => {cacheKey}; Set<Column> get primaryKey => {cacheKey};
} }
@@ -191,7 +195,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 5; int get schemaVersion => 6;
@override @override
MigrationStrategy get migration { 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 ON cached_queue (player_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player_position ON cached_queue (player_id, position)'); 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 /// Cache a library item
/// [sourceProvider] - the provider instance ID that provided this item (for filtering)
Future<void> cacheItem({ Future<void> cacheItem({
required String itemType, required String itemType,
required String itemId, required String itemId,
required String data, required String data,
String? sourceProvider,
}) async { }) async {
final cacheKey = '${itemType}_$itemId'; final cacheKey = '${itemType}_$itemId';
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert( // If sourceProvider is specified, we need to merge with existing providers
cacheKey: cacheKey, if (sourceProvider != null) {
itemType: itemType, final existing = await getCachedItem(itemType, itemId);
itemId: itemId, List<String> providers = [];
data: data, if (existing != null) {
lastSynced: DateTime.now(), 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 /// Get cached items by type
@@ -650,4 +695,66 @@ class AppDatabase extends _$AppDatabase {
Future<void> clearSearchHistory() async { Future<void> clearSearchHistory() async {
await delete(searchHistory).go(); await delete(searchHistory).go();
} }
// ============================================
// Batch Cache Operations
// ============================================
/// Batch size for chunked inserts to prevent SQLite limits
static const int _batchChunkSize = 500;
/// Batch cache multiple items efficiently using drift batch API
/// This is much faster than individual inserts for large datasets.
/// Items are chunked into batches of 500 to prevent SQLite variable limits.
///
/// [items] - List of items to cache, each containing:
/// - itemType: 'album', 'artist', 'track', 'playlist', 'audiobook', etc.
/// - itemId: the item's ID
/// - data: JSON string of the item
/// - sourceProviders: list of provider instance IDs
Future<void> batchCacheItems(List<BatchCacheItem> items) async {
if (items.isEmpty) return;
// Process in chunks to avoid SQLite variable limits
for (var i = 0; i < items.length; i += _batchChunkSize) {
final chunk = items.skip(i).take(_batchChunkSize).toList();
await batch((b) {
for (final item in chunk) {
final cacheKey = '${item.itemType}_${item.itemId}';
final sourceProvidersJson = item.sourceProviders.isEmpty
? '[]'
: '[${item.sourceProviders.map((p) => '"$p"').join(',')}]';
b.insert(
libraryCache,
LibraryCacheCompanion.insert(
cacheKey: cacheKey,
itemType: item.itemType,
itemId: item.itemId,
data: item.data,
lastSynced: DateTime.now(),
sourceProviders: Value(sourceProvidersJson),
),
mode: InsertMode.insertOrReplace,
);
}
});
}
}
}
/// Data class for batch cache operations
class BatchCacheItem {
final String itemType;
final String itemId;
final String data;
final List<String> sourceProviders;
const BatchCacheItem({
required this.itemType,
required this.itemId,
required this.data,
this.sourceProviders = const [],
});
} }
File diff suppressed because it is too large Load Diff
+252
View File
@@ -1140,6 +1140,10 @@
"audiobookLibraries": "Audiobook Libraries", "audiobookLibraries": "Audiobook Libraries",
"chooseAudiobookLibraries": "Choose which Audiobookshelf libraries to include", "chooseAudiobookLibraries": "Choose which Audiobookshelf libraries to include",
"unknownLibrary": "Unknown Library", "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", "noSeriesFound": "No Series Found",
"ensembleBugReport": "Ensemble Bug Report", "ensembleBugReport": "Ensemble Bug Report",
"byOwner": "By {owner}", "byOwner": "By {owner}",
@@ -1288,6 +1292,16 @@
"description": "Description for smart sort players setting" "description": "Description for smart sort players setting"
}, },
"disableAutoSwitch": "Disable Auto-Switch",
"@disableAutoSwitch": {
"description": "Setting to disable automatic player switching"
},
"disableAutoSwitchDescription": "Never automatically switch to a different player",
"@disableAutoSwitchDescription": {
"description": "Description for disable auto-switch setting"
},
"playerStateUnavailable": "Unavailable", "playerStateUnavailable": "Unavailable",
"@playerStateUnavailable": { "@playerStateUnavailable": {
"description": "Player state when unavailable" "description": "Player state when unavailable"
@@ -1343,6 +1357,21 @@
"description": "Description for show hints toggle" "description": "Description for show hints toggle"
}, },
"display": "Display",
"@display": {
"description": "Settings section title for display options"
},
"showProviderIcons": "Show Provider Icons",
"@showProviderIcons": {
"description": "Toggle label for showing provider icons on album art"
},
"showProviderIconsDescription": "Display provider logos on album art",
"@showProviderIconsDescription": {
"description": "Description for show provider icons toggle"
},
"pullToSelectPlayers": "Pull to select players", "pullToSelectPlayers": "Pull to select players",
"@pullToSelectPlayers": { "@pullToSelectPlayers": {
"description": "Hint shown when mini player bounces" "description": "Hint shown when mini player bounces"
@@ -1401,5 +1430,228 @@
"type": "String" "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"
},
"selectPlayerToPlay": "Select a player",
"@selectPlayerToPlay": {
"description": "Generic hint for selecting a player in player selector"
},
"selectPlayerToPlayAlbum": "Select player to play album",
"@selectPlayerToPlayAlbum": {
"description": "Hint for selecting player to play album"
},
"selectPlayerToPlayPlaylist": "Select player to play playlist",
"@selectPlayerToPlayPlaylist": {
"description": "Hint for selecting player to play playlist"
},
"selectPlayerForRadio": "Select player for radio",
"@selectPlayerForRadio": {
"description": "Hint for selecting player to start radio"
},
"selectPlayerForEpisode": "Select player for episode",
"@selectPlayerForEpisode": {
"description": "Hint for selecting player to play episode"
},
"selectPlayerForAudiobook": "Select player for audiobook",
"@selectPlayerForAudiobook": {
"description": "Hint for selecting player to play audiobook"
},
"selectPlayerToAddToQueue": "Select player to add to queue",
"@selectPlayerToAddToQueue": {
"description": "Hint for selecting player to add to queue"
} }
} }
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
+30 -2
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -33,6 +34,10 @@ final _logger = DebugLogger();
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Initialize SharedPreferences cache early for performance
await SettingsService.initialize();
_logger.log('⚡ SharedPreferences cached');
// Initialize local database // Initialize local database
await DatabaseService.instance.initialize(); await DatabaseService.instance.initialize();
_logger.log('💾 Database initialized'); _logger.log('💾 Database initialized');
@@ -84,7 +89,29 @@ Future<void> main() async {
), ),
); );
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 { class MusicAssistantApp extends StatefulWidget {
@@ -196,8 +223,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
} }
// Then check expanded player // Then check expanded player
// No haptic - Android back gesture provides its own
if (GlobalPlayerOverlay.isPlayerExpanded) { if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer(); GlobalPlayerOverlay.collapsePlayer(withHaptic: false);
return true; // We handled it, don't let Navigator process it return true; // We handled it, don't let Navigator process it
} }
+19
View File
@@ -16,6 +16,10 @@ class ProviderMapping {
final String providerInstance; final String providerInstance;
final bool available; final bool available;
final Map<String, dynamic>? audioFormat; 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({ ProviderMapping({
required this.itemId, required this.itemId,
@@ -23,6 +27,7 @@ class ProviderMapping {
required this.providerInstance, required this.providerInstance,
required this.available, required this.available,
this.audioFormat, this.audioFormat,
this.inLibrary = true,
}); });
factory ProviderMapping.fromJson(Map<String, dynamic> json) { factory ProviderMapping.fromJson(Map<String, dynamic> json) {
@@ -32,6 +37,8 @@ class ProviderMapping {
providerInstance: json['provider_instance'] as String? ?? '', providerInstance: json['provider_instance'] as String? ?? '',
available: json['available'] as bool? ?? true, available: json['available'] as bool? ?? true,
audioFormat: json['audio_format'] as Map<String, dynamic>?, 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, 'provider_instance': providerInstance,
'available': available, 'available': available,
if (audioFormat != null) 'audio_format': audioFormat, if (audioFormat != null) 'audio_format': audioFormat,
'in_library': inLibrary,
}; };
} }
} }
@@ -118,6 +126,17 @@ class MediaItem {
if (duration != null) 'duration': duration!.inSeconds, if (duration != null) 'duration': duration!.inSeconds,
}; };
} }
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MediaItem &&
runtimeType == other.runtimeType &&
itemId == other.itemId &&
provider == other.provider;
@override
int get hashCode => Object.hash(itemId, provider);
} }
class Artist extends MediaItem { class Artist extends MediaItem {
+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;
}
+65
View File
@@ -0,0 +1,65 @@
/// Represents a provider manifest from Music Assistant.
///
/// Contains metadata about a provider type including its icons.
/// This is separate from ProviderInstance which represents a configured
/// instance of a provider.
class ProviderManifest {
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Display name for this provider type
final String name;
/// Description of the provider
final String? description;
/// SVG icon as XML string
final String? iconSvg;
/// SVG icon for dark theme as XML string
final String? iconSvgDark;
/// Monochrome SVG icon as XML string
final String? iconSvgMonochrome;
/// Material Design icon name (fallback)
final String? icon;
ProviderManifest({
required this.domain,
required this.name,
this.description,
this.iconSvg,
this.iconSvgDark,
this.iconSvgMonochrome,
this.icon,
});
/// Whether this manifest has any SVG icon available
bool get hasSvgIcon =>
iconSvg != null || iconSvgDark != null || iconSvgMonochrome != null;
/// Get the best available SVG icon for the given brightness
/// Prefers dark icon for dark themes, falls back to regular or monochrome
String? getSvgIcon({bool isDark = false}) {
if (isDark && iconSvgDark != null) {
return iconSvgDark;
}
return iconSvg ?? iconSvgMonochrome;
}
factory ProviderManifest.fromJson(Map<String, dynamic> json) {
return ProviderManifest(
domain: json['domain'] as String? ?? '',
name: json['name'] as String? ?? '',
description: json['description'] as String?,
iconSvg: json['icon_svg'] as String?,
iconSvgDark: json['icon_svg_dark'] as String?,
iconSvgMonochrome: json['icon_svg_monochrome'] as String?,
icon: json['icon'] as String?,
);
}
@override
String toString() => 'ProviderManifest($domain: $name)';
}
+157 -170
View File
@@ -2,168 +2,80 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../services/music_assistant_api.dart'; import '../services/music_assistant_api.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/database_service.dart';
import '../services/profile_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/error_handler.dart'; import '../services/error_handler.dart';
import '../services/auth/auth_manager.dart'; import '../services/auth/auth_manager.dart';
import '../services/cache_service.dart';
/// Provider for managing connection state to Music Assistant server /// Callback types for connection events
/// Handles WebSocket connection, authentication, and reconnection typedef OnConnected = Future<void> Function();
typedef OnAuthenticated = Future<void> Function();
typedef OnDisconnected = void Function();
/// Provider for connection state management.
///
/// Extracted from MusicAssistantProvider to handle:
/// - WebSocket connection lifecycle
/// - Authentication flow (MA token, credentials)
/// - Reconnection logic
/// - Connection state broadcasting
///
/// Note: This is a partial extraction. Full integration requires updating
/// all consumers to use this provider directly.
class ConnectionProvider with ChangeNotifier { class ConnectionProvider with ChangeNotifier {
MusicAssistantAPI? _api;
final AuthManager _authManager = AuthManager();
final DebugLogger _logger = DebugLogger(); final DebugLogger _logger = DebugLogger();
final CacheService _cacheService = CacheService(); final AuthManager _authManager = AuthManager();
MAConnectionState _connectionState = MAConnectionState.disconnected; MusicAssistantAPI? _api;
String? _serverUrl; String? _serverUrl;
String? _error; String? _error;
MAConnectionState _connectionState = MAConnectionState.disconnected;
// Callbacks for post-connection initialization StreamSubscription? _connectionStateSubscription;
VoidCallback? onConnected;
VoidCallback? onAuthenticated;
VoidCallback? onDisconnected;
MAConnectionState get connectionState => _connectionState; // Callbacks for connection events - set by the facade provider
String? get serverUrl => _serverUrl; OnConnected? onConnected;
String? get error => _error; OnAuthenticated? onAuthenticated;
bool get isConnected => _connectionState == MAConnectionState.connected || OnDisconnected? onDisconnected;
_connectionState == MAConnectionState.authenticated;
// User settings captured during authentication
List<String> _providerFilter = [];
List<String> _playerFilter = [];
// ============================================================================
// GETTERS
// ============================================================================
MusicAssistantAPI? get api => _api; MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager; AuthManager get authManager => _authManager;
CacheService get cacheService => _cacheService; String? get serverUrl => _serverUrl;
String? get error => _error;
MAConnectionState get connectionState => _connectionState;
ConnectionProvider() { bool get isConnected =>
_initialize(); _connectionState == MAConnectionState.connected ||
} _connectionState == MAConnectionState.authenticated;
Future<void> _initialize() async { List<String> get providerFilter => _providerFilter;
_serverUrl = await SettingsService.getServerUrl(); List<String> get playerFilter => _playerFilter;
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
await _restoreAuthCredentials();
await connectToServer(_serverUrl!);
}
}
/// Restore auth credentials from persistent storage // ============================================================================
Future<void> _restoreAuthCredentials() async { // CONNECTION
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 /// Connect to the Music Assistant server
Future<bool> handleMaAuthentication() async { Future<void> connect(String serverUrl) 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 { try {
_error = null; _error = null;
_serverUrl = serverUrl; _serverUrl = serverUrl;
await SettingsService.setServerUrl(serverUrl); await SettingsService.setServerUrl(serverUrl);
// Disconnect existing connection // Dispose the old API to stop any pending reconnects
await _api?.disconnect(); _api?.dispose();
_api = MusicAssistantAPI(serverUrl, _authManager); _api = MusicAssistantAPI(serverUrl, _authManager);
// Listen to connection state changes _connectionStateSubscription?.cancel();
_api!.connectionState.listen( _connectionStateSubscription = _api!.connectionState.listen(
(state) async { (state) async {
_connectionState = state; _connectionState = state;
notifyListeners(); notifyListeners();
@@ -173,9 +85,8 @@ class ConnectionProvider with ChangeNotifier {
if (_api!.authRequired && !_api!.isAuthenticated) { if (_api!.authRequired && !_api!.isAuthenticated) {
_logger.log('🔐 MA auth required, attempting authentication...'); _logger.log('🔐 MA auth required, attempting authentication...');
final authenticated = await handleMaAuthentication(); final authenticated = await _handleAuthentication();
if (!authenticated) { if (!authenticated) {
_logger.log('❌ MA authentication failed - stopping connection flow');
_error = 'Authentication required. Please log in again.'; _error = 'Authentication required. Please log in again.';
notifyListeners(); notifyListeners();
return; return;
@@ -183,15 +94,13 @@ class ConnectionProvider with ChangeNotifier {
return; return;
} }
// No auth required - notify connected // No auth required, trigger callback
onConnected?.call(); await onConnected?.call();
} else if (state == MAConnectionState.authenticated) { } else if (state == MAConnectionState.authenticated) {
_logger.log('✅ MA authentication successful'); _logger.log('✅ MA authentication successful');
onAuthenticated?.call(); await onAuthenticated?.call();
} else if (state == MAConnectionState.disconnected) { } else if (state == MAConnectionState.disconnected) {
// DON'T clear caches on disconnect - keep showing cached data _logger.log('📡 Disconnected - keeping cached data for instant resume');
// This allows instant UI display when app resumes or reconnects
// Caches will be refreshed when connection is restored
onDisconnected?.call(); onDisconnected?.call();
} }
}, },
@@ -214,44 +123,122 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<bool> _handleAuthentication() async {
if (_api == null) return false;
try {
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 _fetchUserSettings();
return true;
}
_logger.log('⚠️ Stored MA token invalid, clearing...');
await SettingsService.clearMaAuthToken();
}
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 _fetchUserSettings();
return true;
}
}
_logger.log('❌ MA authentication failed - no valid token or credentials');
return false;
} catch (e) {
_logger.log('❌ MA authentication error: $e');
return false;
}
}
Future<void> _fetchUserSettings() async {
if (_api == null) return;
try {
final userInfo = await _api!.getCurrentUserInfo();
if (userInfo == null) return;
// Set profile name
final displayName = userInfo['display_name'] as String?;
final username = userInfo['username'] as String?;
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');
}
// Capture provider filter
final providerFilterRaw = userInfo['provider_filter'];
if (providerFilterRaw is List) {
_providerFilter = providerFilterRaw.cast<String>().toList();
if (_providerFilter.isNotEmpty) {
_logger.log(
'🔒 Provider filter active: ${_providerFilter.length} providers allowed');
}
} else {
_providerFilter = [];
}
// Capture player filter
final playerFilterRaw = userInfo['player_filter'];
if (playerFilterRaw is List) {
_playerFilter = playerFilterRaw.cast<String>().toList();
if (_playerFilter.isNotEmpty) {
_logger.log(
'🔒 Player filter active: ${_playerFilter.length} players allowed');
}
} else {
_playerFilter = [];
}
} catch (e) {
_logger.log('⚠️ Could not fetch user settings (non-fatal): $e');
}
}
/// Disconnect from the server
Future<void> disconnect() async { Future<void> disconnect() async {
_connectionStateSubscription?.cancel();
await _api?.disconnect(); await _api?.disconnect();
_connectionState = MAConnectionState.disconnected; _connectionState = MAConnectionState.disconnected;
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
notifyListeners(); notifyListeners();
} }
/// Clear all caches and state (for logout or server change) /// Check connection and reconnect if needed
void clearCachesOnLogout() {
_cacheService.clearAll();
notifyListeners();
}
/// Check connection and reconnect if needed (called when app resumes)
Future<void> checkAndReconnect() async { Future<void> checkAndReconnect() async {
_logger.log('🔄 checkAndReconnect called - state: $_connectionState'); if (_serverUrl != null && !isConnected) {
_logger.log('🔄 Attempting reconnection...');
if (_serverUrl == null) { await connect(_serverUrl!);
_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 @override
void dispose() { void dispose() {
_connectionStateSubscription?.cancel();
_api?.dispose(); _api?.dispose();
super.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();
}
}
+571 -100
View File
@@ -7,6 +7,8 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart'; import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../models/player.dart'; import '../models/player.dart';
import '../models/provider_instance.dart';
import '../models/provider_manifest.dart';
import '../services/music_assistant_api.dart'; import '../services/music_assistant_api.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -62,6 +64,8 @@ class MusicAssistantProvider with ChangeNotifier {
Player? _selectedPlayer; Player? _selectedPlayer;
List<Player> _availablePlayers = []; List<Player> _availablePlayers = [];
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer() bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
DateTime? _lastPlayerSwitchTime; // Debounce for auto-selection
static const _playerSwitchCooldown = Duration(seconds: 3); // Minimum time between auto-switches
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
Track? _currentTrack; Track? _currentTrack;
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters) Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
@@ -69,6 +73,12 @@ class MusicAssistantProvider with ChangeNotifier {
Timer? _playerStateTimer; Timer? _playerStateTimer;
Timer? _notificationPositionTimer; // Updates notification position every second for remote players Timer? _notificationPositionTimer; // Updates notification position every second for remote players
// Sleep timer state
Timer? _sleepTimer;
DateTime? _sleepTimerEndTime;
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
Timer? _sleepTimerDisplayTimer; // Updates remaining time display every second
// Local player state // Local player state
bool _isLocalPlayerPowered = true; bool _isLocalPlayerPowered = true;
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100) int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
@@ -114,6 +124,13 @@ class MusicAssistantProvider with ChangeNotifier {
// Empty list means all players are allowed // Empty list means all players are allowed
List<String> _playerFilter = []; 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 // GETTERS
// ============================================================================ // ============================================================================
@@ -134,6 +151,8 @@ class MusicAssistantProvider with ChangeNotifier {
// Raw unfiltered access (for internal use only) // Raw unfiltered access (for internal use only)
List<Artist> get artistsUnfiltered => _artists; List<Artist> get artistsUnfiltered => _artists;
List<Album> get albumsUnfiltered => _albums; List<Album> get albumsUnfiltered => _albums;
List<MediaItem> get podcastsUnfiltered => _podcasts;
List<MediaItem> get radioStationsUnfiltered => _radioStations;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
bool get isLoadingRadio => _isLoadingRadio; bool get isLoadingRadio => _isLoadingRadio;
bool get isLoadingPodcasts => _isLoadingPodcasts; bool get isLoadingPodcasts => _isLoadingPodcasts;
@@ -204,6 +223,225 @@ class MusicAssistantProvider with ChangeNotifier {
return players.where(isPlayerAllowedByFilter).toList(); return players.where(isPlayerAllowedByFilter).toList();
} }
// ============================================================================
// USER-CONTROLLED MUSIC PROVIDER FILTER
// ============================================================================
/// List of available music providers from MA
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
/// Get all provider manifests (contains icons)
Map<String, ProviderManifest> get providerManifests =>
_api?.providerManifests ?? {};
/// Get SVG icon for a provider domain
/// Returns null if no icon is available
String? getProviderIconSvg(String domain, {bool isDark = false}) {
return _api?.getProviderIconSvg(domain, isDark: isDark);
}
/// Get provider manifest by domain
ProviderManifest? getProviderManifest(String domain) {
return _api?.getProviderManifest(domain);
}
/// 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 /// Whether library is syncing in background
bool get isSyncing => SyncService.instance.isSyncing; bool get isSyncing => SyncService.instance.isSyncing;
@@ -296,6 +534,15 @@ class MusicAssistantProvider with ChangeNotifier {
Track? get currentTrack => _currentTrack; Track? get currentTrack => _currentTrack;
// Sleep timer getters
bool get sleepTimerActive => _sleepTimerMinutes != null;
int? get sleepTimerMinutes => _sleepTimerMinutes;
Duration? get sleepTimerRemaining {
if (_sleepTimerEndTime == null) return null;
final remaining = _sleepTimerEndTime!.difference(DateTime.now());
return remaining.isNegative ? Duration.zero : remaining;
}
/// Whether we have cached players available (for instant UI display on app resume) /// Whether we have cached players available (for instant UI display on app resume)
bool get hasCachedPlayers => _cacheService.hasCachedPlayers; bool get hasCachedPlayers => _cacheService.hasCachedPlayers;
@@ -377,7 +624,7 @@ class MusicAssistantProvider with ChangeNotifier {
} }
String get lastSearchQuery => _lastSearchQuery; String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => filterSearchResults(_lastSearchResults); Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
MusicAssistantAPI? get api => _api; MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager; AuthManager get authManager => _authManager;
@@ -501,6 +748,12 @@ class MusicAssistantProvider with ChangeNotifier {
return getImageUrl(track, size: size); 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 /// Number of pending offline actions
int get pendingOfflineActionsCount => OfflineActionQueue.instance.pendingCount; int get pendingOfflineActionsCount => OfflineActionQueue.instance.pendingCount;
@@ -944,6 +1197,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 { Future<void> _initializeAfterConnection() async {
if (_api == null) return; if (_api == null) return;
@@ -956,6 +1286,9 @@ class MusicAssistantProvider with ChangeNotifier {
await _fetchUserSettings(); await _fetchUserSettings();
} }
// Load available music providers and user's filter preferences
await loadMusicProviders();
await _tryAdoptGhostPlayer(); await _tryAdoptGhostPlayer();
await _registerLocalPlayer(); await _registerLocalPlayer();
await _loadAndSelectPlayers(coldStart: true); await _loadAndSelectPlayers(coldStart: true);
@@ -2721,7 +3054,10 @@ class MusicAssistantProvider with ChangeNotifier {
try { try {
_logger.log('🔄 Fetching fresh discover artists...'); _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); _cacheService.setCachedDiscoverArtists(artists);
return artists; return artists;
} catch (e) { } catch (e) {
@@ -2740,7 +3076,10 @@ class MusicAssistantProvider with ChangeNotifier {
try { try {
_logger.log('🔄 Fetching fresh discover albums...'); _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); _cacheService.setCachedDiscoverAlbums(albums);
return albums; return albums;
} catch (e) { } catch (e) {
@@ -2764,10 +3103,14 @@ class MusicAssistantProvider with ChangeNotifier {
/// Force a full library sync (for pull-to-refresh) /// Force a full library sync (for pull-to-refresh)
Future<void> forceLibrarySync() async { 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...'); final providerIds = providerIdsForApiCalls;
await SyncService.instance.forceSync(_api!); _logger.log('🔄 Forcing full library sync with providers: ${providerIds ?? "ALL"}');
await SyncService.instance.forceSync(_api!, providerInstanceIds: providerIds);
// Update local lists from sync result // Update local lists from sync result
_albums = SyncService.instance.cachedAlbums; _albums = SyncService.instance.cachedAlbums;
@@ -2775,7 +3118,10 @@ class MusicAssistantProvider with ChangeNotifier {
// Also refresh tracks from API // Also refresh tracks from API
try { try {
_tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems); _tracks = await _api!.getTracks(
limit: LibraryConstants.maxLibraryItems,
providerInstanceIds: providerIdsForApiCalls,
);
} catch (e) { } catch (e) {
_logger.log('⚠️ Failed to refresh tracks: $e'); _logger.log('⚠️ Failed to refresh tracks: $e');
} }
@@ -3083,12 +3429,11 @@ class MusicAssistantProvider with ChangeNotifier {
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) { if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)'); _logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)');
return filterSearchResults(_cacheService.getCachedSearchResults(cacheKey)!); return _cacheService.getCachedSearchResults(cacheKey)!;
} }
if (_api == null) { if (_api == null) {
final cached = _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []}; return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return filterSearchResults(cached);
} }
try { try {
@@ -3105,11 +3450,10 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.setCachedSearchResults(cacheKey, cachedResults); _cacheService.setCachedSearchResults(cacheKey, cachedResults);
_logger.log('✅ Cached search results for "$query"'); _logger.log('✅ Cached search results for "$query"');
return filterSearchResults(cachedResults); return cachedResults;
} catch (e) { } catch (e) {
_logger.log('❌ Search failed: $e'); _logger.log('❌ Search failed: $e');
final cached = _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []}; return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return filterSearchResults(cached);
} }
} }
@@ -3401,8 +3745,13 @@ class MusicAssistantProvider with ChangeNotifier {
if (_availablePlayers.isNotEmpty) { if (_availablePlayers.isNotEmpty) {
Player? playerToSelect; Player? playerToSelect;
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer(); final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId(); final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
// Log settings for debugging player selection issues
_logger.log('⚙️ Player selection settings: preferLocal=$preferLocalPlayer, disableAutoSwitch=$disableAutoSwitch, coldStart=$coldStart');
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId');
// If "Prefer Local Player" is ON, always try to select local player first // If "Prefer Local Player" is ON, always try to select local player first
// This takes priority even over the currently selected player // This takes priority even over the currently selected player
if (preferLocalPlayer && builtinPlayerId != null) { if (preferLocalPlayer && builtinPlayerId != null) {
@@ -3413,12 +3762,12 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('📱 Auto-selected local player (preferred): ${playerToSelect?.name}'); _logger.log('📱 Auto-selected local player (preferred): ${playerToSelect?.name}');
} catch (e) { } catch (e) {
// Local player not available yet, will fall through to other options // Local player not available yet, will fall through to other options
_logger.log('⚠️ Prefer local player ON but local player not available');
} }
} }
// Keep currently selected player if still available (and not overridden by prefer local) // Keep currently selected player if still available (and not overridden by prefer local)
// But allow switching to a playing player when preferLocalPlayer is OFF // On coldStart, skip this block and apply full priority logic
// On coldStart, skip this block and apply full priority logic (playing > local > last selected)
if (playerToSelect == null && _selectedPlayer != null && !coldStart) { if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
// Check if selected player is still available - also check translated Cast/Sendspin IDs // Check if selected player is still available - also check translated Cast/Sendspin IDs
// When Cast player gets replaced by Sendspin version (or vice versa), we should keep selection // When Cast player gets replaced by Sendspin version (or vice versa), we should keep selection
@@ -3445,71 +3794,73 @@ class MusicAssistantProvider with ChangeNotifier {
(reverseTranslatedId != null && p.playerId == reverseTranslatedId), (reverseTranslatedId != null && p.playerId == reverseTranslatedId),
); );
// If preferLocalPlayer is OFF, check if we should switch to a playing player // Keep current selection - no auto-switching during non-coldStart refreshes
if (!preferLocalPlayer) { // Auto-switch to playing player is now ONLY done on coldStart (see below)
final currentIsPlaying = currentPlayer.state == 'playing'; playerToSelect = currentPlayer;
// Exclude external sources - they're not playing MA content if (currentPlayer.playerId != selectedId) {
final playingPlayers = _availablePlayers.where( _logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
(p) => p.state == 'playing' && p.available && !p.isExternalSource, } else {
).toList(); _logger.log('✓ Keeping current selection: ${currentPlayer.name}');
// Switch to playing player only if current isn't playing and exactly one other is
if (!currentIsPlaying && playingPlayers.length == 1) {
playerToSelect = playingPlayers.first;
_logger.log('🎵 Switched to playing player: ${playerToSelect?.name}');
}
}
// Keep current selection if no switch happened
if (playerToSelect == null) {
playerToSelect = currentPlayer;
if (currentPlayer.playerId != selectedId) {
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
}
} }
} }
} }
if (coldStart) { if (coldStart) {
_logger.log('🚀 Cold start: applying full priority logic (playing > local > last selected)'); _logger.log('🚀 Cold start: applying full priority logic');
} }
if (playerToSelect == null) { if (playerToSelect == null) {
// Smart auto-selection priority: // Smart auto-selection priority (coldStart only for auto-switch to playing):
// If "Prefer Local Player" is OFF: Single playing player -> Local player -> Last selected -> First available // Priority 1: Single playing player (only on coldStart and if disableAutoSwitch is OFF)
// Priority 2: Local player (if preferLocalPlayer is OFF but we still try local as fallback)
// Priority 3: Last manually selected player
// Priority 4: First available player
if (!preferLocalPlayer) { // Priority 1: Auto-switch to single playing player
// Priority 1 (normal): Single playing player (skip if multiple are playing) // ONLY on coldStart AND only if disableAutoSwitch is OFF
// Exclude external sources - they're not playing MA content // Also apply cooldown to prevent rapid successive auto-switches
final cooldownActive = _lastPlayerSwitchTime != null &&
DateTime.now().difference(_lastPlayerSwitchTime!) < _playerSwitchCooldown;
if (coldStart && !disableAutoSwitch && !preferLocalPlayer && !cooldownActive) {
final playingPlayers = _availablePlayers.where( final playingPlayers = _availablePlayers.where(
(p) => p.state == 'playing' && p.available && !p.isExternalSource, (p) => p.state == 'playing' && p.available && !p.isExternalSource,
).toList(); ).toList();
if (playingPlayers.length == 1) { if (playingPlayers.length == 1) {
playerToSelect = playingPlayers.first; playerToSelect = playingPlayers.first;
_lastPlayerSwitchTime = DateTime.now(); // Update cooldown timestamp
_logger.log('🎵 Auto-selected playing player: ${playerToSelect?.name}'); _logger.log('🎵 Auto-selected playing player: ${playerToSelect?.name}');
} else if (playingPlayers.length > 1) {
_logger.log('⚠️ Multiple players playing (${playingPlayers.length}), skipping auto-switch');
} }
} else if (cooldownActive) {
_logger.log('⏳ Auto-switch cooldown active, skipping');
} else if (!coldStart) {
_logger.log('⚠️ Not coldStart, skipping auto-switch to playing player');
} else if (disableAutoSwitch) {
_logger.log('⚠️ Auto-switch disabled by user setting');
}
// Priority 2: Local player // Priority 2: Local player (fallback even when preferLocalPlayer is OFF)
if (playerToSelect == null && builtinPlayerId != null) { if (playerToSelect == null && builtinPlayerId != null) {
try { try {
playerToSelect = _availablePlayers.firstWhere( playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available, (p) => p.playerId == builtinPlayerId && p.available,
); );
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}'); _logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
} catch (e) { } catch (e) {
// Local player not found or not available - check why // Local player not found or not available - check why
final localPlayer = _availablePlayers final localPlayer = _availablePlayers
.where((p) => p.playerId == builtinPlayerId) .where((p) => p.playerId == builtinPlayerId)
.toList(); .toList();
if (localPlayer.isEmpty) { if (localPlayer.isEmpty) {
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list'); _logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
} else { } else {
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}'); _logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
}
} }
} else if (playerToSelect == null && builtinPlayerId == null) {
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
} }
} else if (playerToSelect == null && builtinPlayerId == null) {
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
} }
// Priority 3: Last manually selected player // Priority 3: Last manually selected player
@@ -3529,6 +3880,7 @@ class MusicAssistantProvider with ChangeNotifier {
(p) => p.available, (p) => p.available,
orElse: () => _availablePlayers.first, orElse: () => _availablePlayers.first,
); );
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
} }
} }
@@ -3564,12 +3916,10 @@ class MusicAssistantProvider with ChangeNotifier {
// IMPORTANT: Always set from cache, even if null - this prevents showing // IMPORTANT: Always set from cache, even if null - this prevents showing
// stale track info when switching to a non-playing player. // stale track info when switching to a non-playing player.
// For grouped child players, this returns the leader's track // For grouped child players, this returns the leader's track
// IMPORTANT: Clear track for external sources - they're playing non-MA content // NOTE: Always use cache even for external sources - the overlay shows cached
if (player.isExternalSource) { // track info, so we should match that. The async _updatePlayerState() will
_currentTrack = null; // clear it if the player is truly playing external content.
} else { _currentTrack = getCachedTrackForPlayer(player.playerId);
_currentTrack = getCachedTrackForPlayer(player.playerId);
}
// Initialize position tracker for this player (skip for external sources) // Initialize position tracker for this player (skip for external sources)
_positionTracker.onPlayerSelected(player.playerId); _positionTracker.onPlayerSelected(player.playerId);
@@ -3620,6 +3970,9 @@ class MusicAssistantProvider with ChangeNotifier {
item: mediaItem, item: mediaItem,
playing: player.state == 'playing', playing: player.state == 'playing',
); );
} else {
// Builtin player is idle - clear notification (fixes issue #42)
audioHandler.clearRemotePlaybackState();
} }
} else { } else {
// For remote players, immediately show notification if we have cached track info // For remote players, immediately show notification if we have cached track info
@@ -3662,6 +4015,10 @@ class MusicAssistantProvider with ChangeNotifier {
position: position, position: position,
duration: Duration.zero, duration: Duration.zero,
); );
} else {
// Player is idle - clear the notification to prevent stale metadata
// from previous player showing (fixes issue #42)
audioHandler.clearRemotePlaybackState();
} }
} }
@@ -3773,40 +4130,33 @@ class MusicAssistantProvider with ChangeNotifier {
} }
/// Update just the notification position (called every 500ms for remote/Sendspin players) /// Update just the notification position (called every 500ms for remote/Sendspin players)
void _updateNotificationPosition() { /// Set [forceUpdate] to true to update even when paused (used after seek)
void _updateNotificationPosition({bool forceUpdate = false}) {
if (_selectedPlayer == null || _currentTrack == null) { if (_selectedPlayer == null || _currentTrack == null) {
_notificationPositionTimer?.cancel(); _notificationPositionTimer?.cancel();
return; return;
} }
// Don't update if player is not playing final isPlaying = _selectedPlayer!.state == 'playing';
if (_selectedPlayer!.state != 'playing') {
// Don't update if player is not playing (unless forced for seek)
if (!isPlaying && !forceUpdate) {
_notificationPositionTimer?.cancel(); _notificationPositionTimer?.cancel();
return; return;
} }
final track = _currentTrack!; final track = _currentTrack!;
Duration position;
// For Sendspin PCM, use the PCM player's elapsed time (based on bytes played) // Always use position tracker for notification position - it's the single source of truth.
// For remote players, use the position tracker (server-based interpolation) // Previously used PCM elapsed time for Sendspin, but this breaks when:
if (_sendspinConnected && _pcmAudioPlayer != null && _pcmAudioPlayer!.isPlaying) { // 1. Stream starts mid-track (resume from pause) - PCM elapsed resets to 0
position = _pcmAudioPlayer!.elapsedTime; // 2. Switching between multiple Sendspin players - PCM elapsed doesn't match
final position = _positionTracker.currentPosition;
// Check if track has ended based on PCM elapsed time // Check if track has ended (position reached duration)
if (track.duration != null && position >= track.duration!) { if (_positionTracker.hasReachedEnd) {
_logger.log('🔔 Sendspin: Track appears to have ended (position >= duration)'); _logger.log('PositionTracker: Track appears to have ended (position >= duration)');
return; return;
}
} else {
// Use position tracker for remote players
position = _positionTracker.currentPosition;
// Check if track has ended (position reached duration)
if (_positionTracker.hasReachedEnd) {
_logger.log('PositionTracker: Track appears to have ended (position >= duration)');
return;
}
} }
final artworkUrl = _api?.getImageUrl(track, size: 512); final artworkUrl = _api?.getImageUrl(track, size: 512);
@@ -3824,7 +4174,7 @@ class MusicAssistantProvider with ChangeNotifier {
audioHandler.setRemotePlaybackState( audioHandler.setRemotePlaybackState(
item: mediaItem, item: mediaItem,
playing: true, playing: isPlaying,
position: position, position: position,
duration: track.duration, duration: track.duration,
); );
@@ -4009,6 +4359,10 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> _updatePlayerState() async { Future<void> _updatePlayerState() async {
if (_selectedPlayer == null || _api == null) return; if (_selectedPlayer == null || _api == null) return;
// Capture player ID at start to detect stale operations
// If player changes during async operations, we should abort
final operationPlayerId = _selectedPlayer!.playerId;
try { try {
bool stateChanged = false; bool stateChanged = false;
@@ -4027,6 +4381,13 @@ class MusicAssistantProvider with ChangeNotifier {
// Try to refresh from API first for latest state // Try to refresh from API first for latest state
final allPlayers = await getPlayers(); final allPlayers = await getPlayers();
// Check for stale operation - player may have changed during async call
if (_selectedPlayer?.playerId != operationPlayerId) {
_logger.log('⚠️ _updatePlayerState: Player changed during operation, aborting');
return;
}
Player? rawPlayer = allPlayers.firstWhere( Player? rawPlayer = allPlayers.firstWhere(
(p) => p.playerId == selectedId || (p) => p.playerId == selectedId ||
(translatedSendspinId != null && p.playerId == translatedSendspinId) || (translatedSendspinId != null && p.playerId == translatedSendspinId) ||
@@ -4137,6 +4498,12 @@ class MusicAssistantProvider with ChangeNotifier {
final queue = await getQueue(_selectedPlayer!.playerId); final queue = await getQueue(_selectedPlayer!.playerId);
// Check for stale operation - player may have changed during async call
if (_selectedPlayer?.playerId != operationPlayerId) {
_logger.log('⚠️ _updatePlayerState: Player changed during getQueue, aborting');
return;
}
if (queue != null && queue.currentItem != null) { if (queue != null && queue.currentItem != null) {
final queueTrack = queue.currentItem!.track; final queueTrack = queue.currentItem!.track;
final trackChanged = _currentTrack == null || final trackChanged = _currentTrack == null ||
@@ -4144,6 +4511,11 @@ class MusicAssistantProvider with ChangeNotifier {
_currentTrack!.name != queueTrack.name; _currentTrack!.name != queueTrack.name;
if (trackChanged) { if (trackChanged) {
// Check "end of track" sleep timer - previous track just ended
if (_currentTrack != null) {
checkEndOfTrackSleepTimer();
}
// Check if cached track has better metadata (images, proper artist info) // Check if cached track has better metadata (images, proper artist info)
// This prevents losing album art and artist data when resuming the app // This prevents losing album art and artist data when resuming the app
// For grouped child players, this gets the leader's cached track // For grouped child players, this gets the leader's cached track
@@ -4398,7 +4770,10 @@ class MusicAssistantProvider with ChangeNotifier {
// Fetch tracks from API (not cached - too many items) // Fetch tracks from API (not cached - too many items)
if (_api != null) { if (_api != null) {
try { 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'); _logger.log('📥 Fetched ${_tracks.length} tracks from MA');
} catch (e) { } catch (e) {
_logger.log('⚠️ Failed to fetch tracks: $e'); _logger.log('⚠️ Failed to fetch tracks: $e');
@@ -4438,18 +4813,18 @@ class MusicAssistantProvider with ChangeNotifier {
} }
syncService.addListener(onSyncComplete); syncService.addListener(onSyncComplete);
await syncService.syncFromApi(_api!); await syncService.syncFromApi(_api!, providerInstanceIds: providerIdsForApiCalls);
} }
/// Load radio stations from the library /// Load radio stations from the library
Future<void> loadRadioStations() async { Future<void> loadRadioStations({String? orderBy}) async {
if (!isConnected || _api == null) return; if (!isConnected || _api == null) return;
try { try {
_isLoadingRadio = true; _isLoadingRadio = true;
notifyListeners(); notifyListeners();
_radioStations = await _api!.getRadioStations(limit: 100); _radioStations = await _api!.getRadioStations(limit: 100, orderBy: orderBy);
_isLoadingRadio = false; _isLoadingRadio = false;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
@@ -4459,14 +4834,14 @@ class MusicAssistantProvider with ChangeNotifier {
} }
} }
Future<void> loadPodcasts() async { Future<void> loadPodcasts({String? orderBy}) async {
if (!isConnected || _api == null) return; if (!isConnected || _api == null) return;
try { try {
_isLoadingPodcasts = true; _isLoadingPodcasts = true;
notifyListeners(); notifyListeners();
_podcasts = await _api!.getPodcasts(limit: 100); _podcasts = await _api!.getPodcasts(limit: 100, orderBy: orderBy);
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts'); _logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
_isLoadingPodcasts = false; _isLoadingPodcasts = false;
@@ -4509,7 +4884,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; if (!isConnected) return;
try { try {
@@ -4522,6 +4897,7 @@ class MusicAssistantProvider with ChangeNotifier {
offset: offset, offset: offset,
search: search, search: search,
albumArtistsOnly: false, // Show ALL library artists, not just those with albums albumArtistsOnly: false, // Show ALL library artists, not just those with albums
orderBy: orderBy,
); );
_isLoading = false; _isLoading = false;
@@ -4539,6 +4915,7 @@ class MusicAssistantProvider with ChangeNotifier {
int? offset, int? offset,
String? search, String? search,
String? artistId, String? artistId,
String? orderBy,
}) async { }) async {
if (!isConnected) return; if (!isConnected) return;
@@ -4552,6 +4929,7 @@ class MusicAssistantProvider with ChangeNotifier {
offset: offset, offset: offset,
search: search, search: search,
artistId: artistId, artistId: artistId,
orderBy: orderBy,
); );
_isLoading = false; _isLoading = false;
@@ -4582,7 +4960,7 @@ class MusicAssistantProvider with ChangeNotifier {
try { try {
final results = await _api!.search(query, libraryOnly: libraryOnly); final results = await _api!.search(query, libraryOnly: libraryOnly);
return filterSearchResults(results); return results;
} catch (e) { } catch (e) {
ErrorHandler.logError('Search', e); ErrorHandler.logError('Search', e);
return {'artists': [], 'albums': [], 'tracks': []}; return {'artists': [], 'albums': [], 'tracks': []};
@@ -4590,10 +4968,10 @@ class MusicAssistantProvider with ChangeNotifier {
} }
/// Get playlists with provider filtering applied /// 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 []; if (_api == null) return [];
try { try {
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly); final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly, orderBy: orderBy);
return filterByProvider(playlists); return filterByProvider(playlists);
} catch (e) { } catch (e) {
_logger.log('❌ Failed to fetch playlists: $e'); _logger.log('❌ Failed to fetch playlists: $e');
@@ -4976,6 +5354,94 @@ class MusicAssistantProvider with ChangeNotifier {
} }
} }
// ============================================================================
// SLEEP TIMER
// ============================================================================
/// Set sleep timer. Pass null to turn off, -1 for end of track, or minutes.
void setSleepTimer(int? minutes) {
// Cancel any existing timer
_cancelSleepTimerInternal();
_sleepTimerMinutes = minutes;
if (minutes == null) {
// Timer off
_logger.log('😴 Sleep timer: OFF');
notifyListeners();
return;
}
if (minutes == -1) {
// End of track - handled in player state updates
_sleepTimerEndTime = null; // No fixed end time
_logger.log('😴 Sleep timer: End of track');
notifyListeners();
return;
}
// Set timer for specified minutes
_sleepTimerEndTime = DateTime.now().add(Duration(minutes: minutes));
_logger.log('😴 Sleep timer: $minutes minutes (until $_sleepTimerEndTime)');
// Create the actual timer
_sleepTimer = Timer(Duration(minutes: minutes), _onSleepTimerExpired);
// Start display update timer (every second for countdown)
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
notifyListeners(); // Update UI with remaining time
});
notifyListeners();
}
/// Cancel sleep timer
void cancelSleepTimer() {
_cancelSleepTimerInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
_logger.log('😴 Sleep timer: Cancelled');
notifyListeners();
}
void _cancelSleepTimerInternal() {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerDisplayTimer?.cancel();
_sleepTimerDisplayTimer = null;
}
void _onSleepTimerExpired() {
_logger.log('😴 Sleep timer expired - pausing playback');
_cancelSleepTimerInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
// Pause the current player
final playerId = _selectedPlayer?.playerId;
if (playerId != null) {
pausePlayer(playerId);
}
notifyListeners();
}
/// Called when track ends - checks if "end of track" sleep timer is active
void checkEndOfTrackSleepTimer() {
if (_sleepTimerMinutes == -1) {
_logger.log('😴 End of track sleep timer triggered');
_sleepTimerMinutes = null;
// Pause the current player
final playerId = _selectedPlayer?.playerId;
if (playerId != null) {
pausePlayer(playerId);
}
notifyListeners();
}
}
/// Sync a player to the currently selected player (temporary group) /// Sync a player to the currently selected player (temporary group)
/// The target player will play the same audio as the selected player /// The target player will play the same audio as the selected player
Future<void> syncPlayerToSelected(String targetPlayerId) async { Future<void> syncPlayerToSelected(String targetPlayerId) async {
@@ -5246,6 +5712,9 @@ class MusicAssistantProvider with ChangeNotifier {
try { try {
// Immediately update position tracker for responsive UI // Immediately update position tracker for responsive UI
_positionTracker.onSeek(position.toDouble()); _positionTracker.onSeek(position.toDouble());
// Immediately update notification position to stay in sync with app
// Use forceUpdate since seek can happen while paused
_updateNotificationPosition(forceUpdate: true);
await _api?.seek(playerId, position); await _api?.seek(playerId, position);
} catch (e) { } catch (e) {
ErrorHandler.logError('Seek', e); ErrorHandler.logError('Seek', e);
@@ -5406,6 +5875,8 @@ class MusicAssistantProvider with ChangeNotifier {
_playerStateTimer?.cancel(); _playerStateTimer?.cancel();
_notificationPositionTimer?.cancel(); _notificationPositionTimer?.cancel();
_localPlayerStateReportTimer?.cancel(); _localPlayerStateReportTimer?.cancel();
_sleepTimer?.cancel();
_sleepTimerDisplayTimer?.cancel();
_connectionStateSubscription?.cancel(); _connectionStateSubscription?.cancel();
_localPlayerEventSubscription?.cancel(); _localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel(); _playerUpdatedEventSubscription?.cancel();
-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();
}
}
+129
View File
@@ -0,0 +1,129 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/debug_logger.dart';
/// Callback type for when sleep timer expires and should pause playback
typedef OnSleepTimerExpired = void Function();
/// Provider for sleep timer functionality.
///
/// Extracted from MusicAssistantProvider to reduce complexity and improve testability.
/// This provider manages:
/// - Timed sleep (pause after X minutes)
/// - End of track sleep (pause when current track ends)
class SleepTimerProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
Timer? _sleepTimer;
DateTime? _sleepTimerEndTime;
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
Timer? _sleepTimerDisplayTimer;
/// Callback to pause playback when timer expires
OnSleepTimerExpired? onExpired;
// ============================================================================
// GETTERS
// ============================================================================
/// Whether a sleep timer is currently active
bool get isActive => _sleepTimerMinutes != null;
/// Current timer setting: null = off, -1 = end of track, positive = minutes
int? get minutes => _sleepTimerMinutes;
/// Time remaining until sleep timer expires (null if end-of-track mode or inactive)
Duration? get remaining {
if (_sleepTimerEndTime == null) return null;
final remaining = _sleepTimerEndTime!.difference(DateTime.now());
return remaining.isNegative ? Duration.zero : remaining;
}
// ============================================================================
// TIMER CONTROL
// ============================================================================
/// Set sleep timer. Pass null to turn off, -1 for end of track, or minutes.
void setTimer(int? minutes) {
// Cancel any existing timer
_cancelInternal();
_sleepTimerMinutes = minutes;
if (minutes == null) {
// Timer off
_logger.log('😴 Sleep timer: OFF');
notifyListeners();
return;
}
if (minutes == -1) {
// End of track - handled in player state updates
_sleepTimerEndTime = null; // No fixed end time
_logger.log('😴 Sleep timer: End of track');
notifyListeners();
return;
}
// Set timer for specified minutes
_sleepTimerEndTime = DateTime.now().add(Duration(minutes: minutes));
_logger.log('😴 Sleep timer: $minutes minutes (until $_sleepTimerEndTime)');
// Create the actual timer
_sleepTimer = Timer(Duration(minutes: minutes), _onTimerExpired);
// Start display update timer (every second for countdown)
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
notifyListeners(); // Update UI with remaining time
});
notifyListeners();
}
/// Cancel sleep timer
void cancel() {
_cancelInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
_logger.log('😴 Sleep timer: Cancelled');
notifyListeners();
}
void _cancelInternal() {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerDisplayTimer?.cancel();
_sleepTimerDisplayTimer = null;
}
void _onTimerExpired() {
_logger.log('😴 Sleep timer expired - pausing playback');
_cancelInternal();
_sleepTimerMinutes = null;
_sleepTimerEndTime = null;
// Trigger the callback to pause playback
onExpired?.call();
notifyListeners();
}
/// Called when track ends - checks if "end of track" sleep timer is active
void checkEndOfTrack() {
if (_sleepTimerMinutes == -1) {
_logger.log('😴 End of track sleep timer triggered');
_sleepTimerMinutes = null;
// Trigger the callback to pause playback
onExpired?.call();
notifyListeners();
}
}
@override
void dispose() {
_cancelInternal();
super.dispose();
}
}
+101 -79
View File
@@ -10,8 +10,9 @@ import '../services/metadata_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/recently_played_service.dart'; import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart'; import '../widgets/provider_icon.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'artist_details_screen.dart'; import 'artist_details_screen.dart';
class AlbumDetailsScreen extends StatefulWidget { class AlbumDetailsScreen extends StatefulWidget {
@@ -222,7 +223,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
try { try {
final newState = !_isInLibrary; final newState = !_isInLibrary;
bool success;
if (newState) { if (newState) {
// Add to library - MUST use non-library provider // 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'); _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', mediaType: 'album',
provider: actualProvider, provider: actualProvider,
itemId: actualItemId, itemId: actualItemId,
); ).catchError((e) {
_logger.log('❌ Failed to add album to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
} else { } else {
// Remove from library // Remove from library
int? libraryItemId; int? libraryItemId;
@@ -279,13 +302,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
return; return;
} }
success = await maProvider.removeFromLibrary( // OPTIMISTIC UPDATE: Update UI immediately
mediaType: 'album',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() { setState(() {
_isInLibrary = newState; _isInLibrary = newState;
}); });
@@ -293,13 +310,25 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(S.of(context)!.removedFromLibrary),
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1), 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) { } catch (e) {
_logger.log('Error toggling album library: $e'); _logger.log('Error toggling album library: $e');
@@ -529,12 +558,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 16), Spacing.vGap16,
Text( Text(
S.of(context)!.addAlbumToQueueOn, S.of(context)!.addAlbumToQueueOn,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 16), Spacing.vGap16,
if (players.isEmpty) if (players.isEmpty)
Padding( Padding(
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
@@ -609,12 +638,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 16), Spacing.vGap16,
Text( Text(
S.of(context)!.addToQueueOn, S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 16), Spacing.vGap16,
if (players.isEmpty) if (players.isEmpty)
Padding( Padding(
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
@@ -672,8 +701,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _navigateToArtist() { void _navigateToArtist() {
// Navigate to the first artist if available // Navigate to the first artist if available
if (widget.album.artists != null && widget.album.artists!.isNotEmpty) { if (_displayAlbum.artists != null && _displayAlbum.artists!.isNotEmpty) {
final artist = widget.album.artists!.first; final artist = _displayAlbum.artists!.first;
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(artist, size: 256); final imageUrl = maProvider.getImageUrl(artist, size: 256);
Navigator.push( Navigator.push(
@@ -849,31 +878,45 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage // FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Container( child: SizedBox(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
color: colorScheme.surfaceVariant, child: Stack(
child: imageUrl != null fit: StackFit.expand,
? CachedNetworkImage( children: [
imageUrl: imageUrl, Container(
fit: BoxFit.cover, color: colorScheme.surfaceVariant,
// Match source memCacheWidth for smooth Hero child: imageUrl != null
memCacheWidth: 256, ? CachedNetworkImage(
memCacheHeight: 256, imageUrl: imageUrl,
fadeInDuration: Duration.zero, fit: BoxFit.cover,
fadeOutDuration: Duration.zero, // Match source memCacheWidth for smooth Hero
placeholder: (_, __) => const SizedBox(), memCacheWidth: 256,
errorWidget: (_, __, ___) => Icon( memCacheHeight: 256,
Icons.album_rounded, fadeInDuration: Duration.zero,
size: coverSize * 0.43, fadeOutDuration: Duration.zero,
color: colorScheme.onSurfaceVariant, placeholder: (_, __) => const SizedBox(),
), errorWidget: (_, __, ___) => Icon(
) Icons.album_rounded,
: Icon( size: coverSize * 0.43,
Icons.album_rounded, color: colorScheme.onSurfaceVariant,
size: coverSize * 0.43, ),
color: colorScheme.onSurfaceVariant, )
: Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
// Provider icon overlay
if (widget.album.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.album.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
), ),
],
),
), ),
), ),
), ),
@@ -902,7 +945,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
), ),
), ),
const SizedBox(height: 8), Spacing.vGap8,
Hero( Hero(
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix, tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
child: Material( child: Material(
@@ -913,7 +956,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0), padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text( child: Text(
widget.album.artistsString, _displayAlbum.artistsString,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7), color: colorScheme.onBackground.withOpacity(0.7),
), ),
@@ -922,7 +965,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
), ),
), ),
const SizedBox(height: 16), Spacing.vGap16,
// Album Description // Album Description
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[ if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
InkWell( InkWell(
@@ -944,7 +987,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
), ),
), ),
const SizedBox(height: 8), Spacing.vGap8,
], ],
Row( Row(
children: [ children: [
@@ -1015,7 +1058,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25), // Circular borderRadius: BorderRadius.circular(Radii.xxl), // Circular
), ),
), ),
child: Icon( child: Icon(
@@ -1193,7 +1236,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(Radii.xxl),
), ),
), ),
child: Icon( child: Icon(
@@ -1216,7 +1259,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
childCount: _tracks.length, 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
], ],
); );
}, },
@@ -1228,13 +1271,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) { void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
await maProvider.playTracks( await maProvider.playTracks(
@@ -1243,48 +1281,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
startIndex: startIndex, startIndex: startIndex,
); );
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
void _showPlayRadioMenu(BuildContext context, int trackIndex) { void _showPlayRadioMenu(BuildContext context, int trackIndex) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final track = _tracks[trackIndex]; final track = _tracks[trackIndex];
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
await maProvider.playRadio(player.playerId, track); await maProvider.playRadio(player.playerId, track);
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
void _showPlayOnMenu(BuildContext context) { void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
await maProvider.playTracks(player.playerId, _tracks); await maProvider.playTracks(player.playerId, _tracks);
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
String _formatDuration(Duration duration) { String _formatDuration(Duration duration) {
+152 -209
View File
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import 'album_details_screen.dart'; import 'album_details_screen.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
@@ -13,6 +14,7 @@ import '../services/settings_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class ArtistDetailsScreen extends StatefulWidget { class ArtistDetailsScreen extends StatefulWidget {
final Artist artist; final Artist artist;
@@ -307,7 +309,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
try { try {
final newState = !_isInLibrary; final newState = !_isInLibrary;
bool success;
if (newState) { if (newState) {
// Add to library - MUST use non-library provider // Add to library - MUST use non-library provider
@@ -338,12 +339,36 @@ 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'); _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', mediaType: 'artist',
provider: actualProvider, provider: actualProvider,
itemId: actualItemId, itemId: actualItemId,
); ).catchError((e) {
_logger.log('❌ Failed to add artist to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
return false;
});
} else { } else {
// Remove from library // Remove from library
int? libraryItemId; int? libraryItemId;
@@ -364,13 +389,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
return; return;
} }
success = await maProvider.removeFromLibrary( // OPTIMISTIC UPDATE: Update UI immediately
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() { setState(() {
_isInLibrary = newState; _isInLibrary = newState;
}); });
@@ -378,13 +397,26 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(S.of(context)!.removedFromLibrary),
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1), 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;
});
}
return false;
});
} }
} catch (e) { } catch (e) {
_logger.log('Error toggling artist library: $e'); _logger.log('Error toggling artist library: $e');
@@ -401,171 +433,73 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
void _showRadioMenu(BuildContext context) { void _showRadioMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// Slide mini player down out of the way GlobalPlayerOverlay.showPlayerSelectorForAction(
GlobalPlayerOverlay.hidePlayer(); contextHint: S.of(context)!.selectPlayerForRadio,
onPlayerSelected: (player) async {
showModalBottomSheet( try {
context: context, await maProvider.playArtistRadio(player.playerId, widget.artist);
backgroundColor: Colors.transparent, if (mounted) {
builder: (context) => Container( ScaffoldMessenger.of(context).showSnackBar(
decoration: BoxDecoration( SnackBar(
color: Theme.of(context).colorScheme.surface, content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), duration: const Duration(seconds: 1),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
S.of(context)!.startRadioOn(widget.artist.name),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try {
await maProvider.playArtistRadio(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error starting artist radio on player: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
},
),
), ),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16), );
], }
), } catch (e) {
), _logger.log('Error starting artist radio on player: $e');
).whenComplete(() { if (mounted) {
// Slide mini player back up when sheet is dismissed ScaffoldMessenger.of(context).showSnackBar(
GlobalPlayerOverlay.showPlayer(); SnackBar(
}); content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
} }
void _showAddToQueueMenu(BuildContext context) { void _showAddToQueueMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// Slide mini player down out of the way GlobalPlayerOverlay.showPlayerSelectorForAction(
GlobalPlayerOverlay.hidePlayer(); contextHint: S.of(context)!.selectPlayerToAddToQueue,
onPlayerSelected: (player) async {
showModalBottomSheet( final api = maProvider.api;
context: context, if (api == null) {
backgroundColor: Colors.transparent, if (mounted) {
builder: (context) => Container( ScaffoldMessenger.of(context).showSnackBar(
decoration: BoxDecoration( SnackBar(content: Text(S.of(context)!.notConnected)),
color: Theme.of(context).colorScheme.surface, );
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), }
), return;
child: Column( }
mainAxisSize: MainAxisSize.min, try {
children: [ await api.playArtistRadioToQueue(player.playerId, widget.artist);
const SizedBox(height: 16), if (mounted) {
Text( ScaffoldMessenger.of(context).showSnackBar(
S.of(context)!.addToQueueOn, SnackBar(
style: Theme.of(context).textTheme.titleLarge, content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
), duration: const Duration(seconds: 1),
const SizedBox(height: 16),
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
final api = maProvider.api;
if (api == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.notConnected)),
);
}
return;
}
try {
await api.playArtistRadioToQueue(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding artist radio to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
},
),
), ),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16), );
], }
), } catch (e) {
), _logger.log('Error adding artist radio to queue: $e');
).whenComplete(() { if (mounted) {
// Slide mini player back up when sheet is dismissed ScaffoldMessenger.of(context).showSnackBar(
GlobalPlayerOverlay.showPlayer(); SnackBar(
}); content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
} }
Future<void> _loadArtistImage() async { Future<void> _loadArtistImage() async {
@@ -786,7 +720,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
), ),
), ),
), ),
const SizedBox(height: 16), Spacing.vGap16,
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[ if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
InkWell( InkWell(
onTap: () { onTap: () {
@@ -807,7 +741,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
), ),
), ),
), ),
const SizedBox(height: 8), Spacing.vGap8,
], ],
// Action Buttons Row // Action Buttons Row
Row( Row(
@@ -861,7 +795,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25), borderRadius: BorderRadius.circular(Radii.xxl),
), ),
), ),
child: Icon( child: Icon(
@@ -897,7 +831,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
), ),
], ],
), ),
const SizedBox(height: 16), Spacing.vGap16,
], ],
), ),
), ),
@@ -947,7 +881,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _toggleSortOrder, onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36), constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
), ),
// View mode toggle // View mode toggle
IconButton( IconButton(
@@ -968,7 +902,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _cycleViewMode, onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36), constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
), ),
], ],
), ),
@@ -1004,7 +938,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _toggleSortOrder, onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36), constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
), ),
IconButton( IconButton(
icon: Icon( icon: Icon(
@@ -1024,7 +958,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _cycleViewMode, onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36), constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
), ),
], ],
], ],
@@ -1032,7 +966,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
), ),
), ),
_buildAlbumSliver(_providerAlbums), _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
], ],
], ],
], ],
@@ -1071,40 +1005,49 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
children: [ children: [
AspectRatio( AspectRatio(
aspectRatio: 1.0, // Square album art aspectRatio: 1.0, // Square album art
child: Hero( child: Stack(
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix', children: [
child: ClipRRect( Hero(
borderRadius: BorderRadius.circular(12), tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
child: Container( child: ClipRRect(
color: colorScheme.surfaceVariant, borderRadius: BorderRadius.circular(12),
child: imageUrl != null child: Container(
? CachedNetworkImage( color: colorScheme.surfaceVariant,
imageUrl: imageUrl, child: imageUrl != null
fit: BoxFit.cover, ? CachedNetworkImage(
width: double.infinity, imageUrl: imageUrl,
height: double.infinity, fit: BoxFit.cover,
fadeInDuration: Duration.zero, width: double.infinity,
fadeOutDuration: Duration.zero, height: double.infinity,
errorWidget: (_, __, ___) => Center( fadeInDuration: Duration.zero,
child: Icon( fadeOutDuration: Duration.zero,
Icons.album_rounded, errorWidget: (_, __, ___) => Center(
size: 64, child: Icon(
color: colorScheme.onSurfaceVariant, Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
) ),
: Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
), ),
), // Provider icon overlay
if (album.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: album.providerMappings!.first.providerDomain,
),
],
), ),
), ),
const SizedBox(height: 8), Spacing.vGap8,
Hero( Hero(
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + '_$heroSuffix', tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + '_$heroSuffix',
child: Material( child: Material(
+46 -31
View File
@@ -5,15 +5,14 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import 'audiobook_detail_screen.dart'; import 'audiobook_detail_screen.dart';
import '../widgets/provider_icon.dart';
class AudiobookAuthorScreen extends StatefulWidget { class AudiobookAuthorScreen extends StatefulWidget {
final String authorName; final String authorName;
@@ -34,7 +33,6 @@ class AudiobookAuthorScreen extends StatefulWidget {
} }
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> { class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final _logger = DebugLogger();
late List<Audiobook> _audiobooks; late List<Audiobook> _audiobooks;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
@@ -150,10 +148,16 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
}, },
child: Scaffold( child: Scaffold(
backgroundColor: colorScheme.surface, 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: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 200, expandedHeight: expandedHeight,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
leading: IconButton( leading: IconButton(
@@ -172,37 +176,41 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
Hero( Hero(
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix, tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
child: Container( child: Container(
width: 200, width: coverSize,
height: 200, height: coverSize,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.primaryContainer, color: colorScheme.primaryContainer,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: ClipOval( child: Stack(
child: _authorImageUrl != null alignment: Alignment.center,
? CachedNetworkImage( children: [
imageUrl: _authorImageUrl!, // Icon always present underneath
fit: BoxFit.cover, Icon(
width: 200, MdiIcons.accountOutline,
height: 200, size: coverSize * 0.5,
fadeInDuration: Duration.zero, color: colorScheme.onPrimaryContainer,
fadeOutDuration: Duration.zero, ),
placeholder: (_, __) => Icon( // Image covers icon when loaded
MdiIcons.accountOutline, if (_authorImageUrl != null)
size: 100, SizedBox(
color: colorScheme.onPrimaryContainer, 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 +295,10 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
), ),
), ),
_buildAudiobookSliver(), _buildAudiobookSliver(),
const SliverToBoxAdapter(child: SizedBox(height: 140)), SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
], ],
);
},
), ),
), ),
); );
@@ -395,6 +405,11 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
minHeight: 3, minHeight: 3,
), ),
), ),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
], ],
), ),
), ),
+57 -49
View File
@@ -5,13 +5,14 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart'; import '../widgets/provider_icon.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/recently_played_service.dart'; import '../services/recently_played_service.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class AudiobookDetailScreen extends StatefulWidget { class AudiobookDetailScreen extends StatefulWidget {
final Audiobook audiobook; final Audiobook audiobook;
@@ -211,13 +212,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
void _showPlayOnMenu(BuildContext context) { void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForAudiobook,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
maProvider.setCurrentAudiobook(_audiobook); maProvider.setCurrentAudiobook(_audiobook);
@@ -226,9 +222,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
widget.audiobook, widget.audiobook,
); );
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
Future<void> _markAsFinished() async { Future<void> _markAsFinished() async {
@@ -504,41 +498,55 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix, tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Container( child: SizedBox(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
color: colorScheme.surfaceContainerHighest, child: Stack(
child: imageUrl != null fit: StackFit.expand,
? CachedNetworkImage( children: [
imageUrl: imageUrl, Container(
fit: BoxFit.cover, color: colorScheme.surfaceContainerHighest,
// Match source memCacheWidth for smooth Hero animation child: imageUrl != null
memCacheWidth: 256, ? CachedNetworkImage(
memCacheHeight: 256, imageUrl: imageUrl,
fadeInDuration: Duration.zero, fit: BoxFit.cover,
fadeOutDuration: Duration.zero, // Match source memCacheWidth for smooth Hero animation
placeholder: (_, __) => Center( memCacheWidth: 256,
child: Icon( memCacheHeight: 256,
MdiIcons.bookOutline, fadeInDuration: Duration.zero,
size: coverSize * 0.43, fadeOutDuration: Duration.zero,
color: colorScheme.onSurfaceVariant, placeholder: (_, __) => Center(
), child: Icon(
), MdiIcons.bookOutline,
errorWidget: (_, __, ___) => Center( size: coverSize * 0.43,
child: Icon( color: colorScheme.onSurfaceVariant,
MdiIcons.bookOutline, ),
size: coverSize * 0.43, ),
color: colorScheme.onSurfaceVariant, errorWidget: (_, __, ___) => Center(
), child: Icon(
), MdiIcons.bookOutline,
) size: coverSize * 0.43,
: Center( color: colorScheme.onSurfaceVariant,
child: Icon( ),
MdiIcons.bookOutline, ),
size: coverSize * 0.43, )
color: colorScheme.onSurfaceVariant, : Center(
), child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.audiobook.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.audiobook.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
), ),
],
),
), ),
), ),
), ),
@@ -566,7 +574,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
), ),
), ),
), ),
const SizedBox(height: 8), Spacing.vGap8,
// Author // Author
Text( Text(
@@ -588,7 +596,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
], ],
// Duration & Progress // Duration & Progress
const SizedBox(height: 16), Spacing.vGap16,
Row( Row(
children: [ children: [
if (book.duration != null) ...[ if (book.duration != null) ...[
@@ -712,7 +720,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25), borderRadius: BorderRadius.circular(Radii.xxl),
), ),
), ),
child: Icon( child: Icon(
@@ -736,7 +744,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 8), Spacing.vGap8,
InkWell( InkWell(
onTap: () { onTap: () {
setState(() { setState(() {
@@ -767,7 +775,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 8), Spacing.vGap8,
], ],
), ),
), ),
@@ -777,7 +785,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Use MA chapters if available, otherwise use ABS chapters // Use MA chapters if available, otherwise use ABS chapters
..._buildChapterSection(book, colorScheme, textTheme), ..._buildChapterSection(book, colorScheme, textTheme),
const SliverToBoxAdapter(child: SizedBox(height: 140)), SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
], ],
); );
}, },
+51 -35
View File
@@ -6,6 +6,7 @@ import 'package:palette_generator/palette_generator.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart' show BottomSpacing; import '../widgets/global_player_overlay.dart' show BottomSpacing;
import '../widgets/provider_icon.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
@@ -690,6 +691,11 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
), ),
), ),
), ),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
// Progress indicator // Progress indicator
if (book.progress > 0) if (book.progress > 0)
Positioned( Positioned(
@@ -748,45 +754,55 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix', tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: Stack( child: SizedBox(
children: [ width: 56,
Container( height: 56,
width: 56, child: Stack(
height: 56, children: [
color: colorScheme.surfaceContainerHighest, Container(
child: imageUrl != null width: 56,
? CachedNetworkImage( height: 56,
imageUrl: imageUrl, color: colorScheme.surfaceContainerHighest,
fit: BoxFit.cover, child: imageUrl != null
fadeInDuration: Duration.zero, ? CachedNetworkImage(
fadeOutDuration: Duration.zero, imageUrl: imageUrl,
placeholder: (_, __) => Icon( fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline, MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
),
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 2,
),
), ),
], // Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
size: 16,
),
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 2,
),
),
],
),
), ),
), ),
), ),
+2 -1
View File
@@ -9,6 +9,7 @@ import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import 'playlist_details_screen.dart'; import 'playlist_details_screen.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class LibraryPlaylistsScreen extends StatefulWidget { class LibraryPlaylistsScreen extends StatefulWidget {
const LibraryPlaylistsScreen({super.key}); const LibraryPlaylistsScreen({super.key});
@@ -168,7 +169,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
trailing: playlist.favorite == true trailing: playlist.favorite == true
? const Icon(Icons.favorite, color: Colors.red, size: 20) ? const Icon(Icons.favorite, color: StatusColors.favorite, size: 20)
: null, : null,
onTap: () { onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl); updateAdaptiveColorsFromImage(context, imageUrl);
File diff suppressed because it is too large Load Diff
+52 -51
View File
@@ -9,8 +9,9 @@ import '../theme/theme_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/recently_played_service.dart'; import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart'; import '../widgets/provider_icon.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class PlaylistDetailsScreen extends StatefulWidget { class PlaylistDetailsScreen extends StatefulWidget {
final Playlist playlist; final Playlist playlist;
@@ -377,20 +378,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
void _showPlayOnMenu(BuildContext context) { void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayPlaylist,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
await maProvider.playTracks(player.playerId, _tracks); await maProvider.playTracks(player.playerId, _tracks);
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
void _addPlaylistToQueue() { void _addPlaylistToQueue() {
@@ -410,12 +404,12 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 16), Spacing.vGap16,
Text( Text(
S.of(context)!.addToQueueOn, S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 16), Spacing.vGap16,
if (players.isEmpty) if (players.isEmpty)
Padding( Padding(
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
@@ -488,12 +482,12 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 16), Spacing.vGap16,
Text( Text(
S.of(context)!.addToQueueOn, S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 16), Spacing.vGap16,
if (players.isEmpty) if (players.isEmpty)
Padding( Padding(
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
@@ -551,20 +545,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final track = _tracks[trackIndex]; final track = _tracks[trackIndex];
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
await maProvider.playRadio(player.playerId, track); await maProvider.playRadio(player.playerId, track);
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
void _showError(String message) { void _showError(String message) {
@@ -705,30 +692,44 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + _heroTagSuffix, tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + _heroTagSuffix,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Container( child: SizedBox(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
color: colorScheme.surfaceContainerHighest, child: Stack(
child: imageUrl != null fit: StackFit.expand,
? CachedNetworkImage( children: [
imageUrl: imageUrl, Container(
fit: BoxFit.cover, color: colorScheme.surfaceContainerHighest,
memCacheWidth: 256, child: imageUrl != null
memCacheHeight: 256, ? CachedNetworkImage(
fadeInDuration: Duration.zero, imageUrl: imageUrl,
fadeOutDuration: Duration.zero, fit: BoxFit.cover,
placeholder: (_, __) => const SizedBox(), memCacheWidth: 256,
errorWidget: (_, __, ___) => Icon( memCacheHeight: 256,
Icons.playlist_play_rounded, fadeInDuration: Duration.zero,
size: coverSize * 0.43, fadeOutDuration: Duration.zero,
color: colorScheme.onSurfaceVariant, placeholder: (_, __) => const SizedBox(),
), errorWidget: (_, __, ___) => Icon(
) Icons.playlist_play_rounded,
: Icon( size: coverSize * 0.43,
Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant,
size: coverSize * 0.43, ),
color: colorScheme.onSurfaceVariant, )
: Icon(
Icons.playlist_play_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
// Provider icon overlay
if (widget.playlist.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.playlist.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
), ),
],
),
), ),
), ),
), ),
@@ -758,7 +759,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
), ),
), ),
), ),
const SizedBox(height: 8), Spacing.vGap8,
// Owner info // Owner info
if (widget.playlist.owner != null) if (widget.playlist.owner != null)
Text( Text(
@@ -775,7 +776,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
color: colorScheme.onSurface.withOpacity(0.5), color: colorScheme.onSurface.withOpacity(0.5),
), ),
), ),
const SizedBox(height: 16), Spacing.vGap16,
// Action buttons row // Action buttons row
Row( Row(
children: [ children: [
@@ -846,7 +847,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25), borderRadius: BorderRadius.circular(Radii.xxl),
), ),
), ),
child: Icon( child: Icon(
@@ -1031,7 +1032,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(Radii.xxl),
), ),
), ),
child: Icon( child: Icon(
@@ -1054,7 +1055,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
childCount: _tracks.length, 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
], ],
); );
}, },
+125 -62
View File
@@ -5,7 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart'; import '../widgets/provider_icon.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -66,7 +66,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
try { try {
final newState = !_isInLibrary; final newState = !_isInLibrary;
bool success;
if (newState) { if (newState) {
// Add to library - MUST use non-library provider // 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'); _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', mediaType: 'podcast',
provider: actualProvider, provider: actualProvider,
itemId: actualItemId, itemId: actualItemId,
); ).catchError((e) {
_logger.log('❌ Failed to add podcast to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
} else { } else {
// Remove from library // Remove from library
int? libraryItemId; int? libraryItemId;
@@ -123,13 +145,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
return; return;
} }
success = await maProvider.removeFromLibrary( // OPTIMISTIC UPDATE: Update UI immediately
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() { setState(() {
_isInLibrary = newState; _isInLibrary = newState;
}); });
@@ -137,13 +153,25 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(S.of(context)!.removedFromLibrary),
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1), 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) { } catch (e) {
_logger.log('Error toggling podcast library: $e'); _logger.log('Error toggling podcast library: $e');
@@ -190,10 +218,38 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
try { try {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) { if (maProvider.api != null) {
final episodes = await maProvider.api!.getPodcastEpisodes( List<MediaItem> episodes = [];
widget.podcast.itemId,
provider: widget.podcast.provider, // 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) { if (mounted) {
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}'); _logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
@@ -252,22 +308,15 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
void _showPlayOnMenu(BuildContext context, MediaItem episode) { void _showPlayOnMenu(BuildContext context, MediaItem episode) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForEpisode,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
// Set podcast context before playing so player UI shows correct podcast name // Set podcast context before playing so player UI shows correct podcast name
maProvider.setCurrentPodcastName(widget.podcast.name); maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(player.playerId, episode); await maProvider.api?.playPodcastEpisode(player.playerId, episode);
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
String _formatDuration(Duration? duration) { String _formatDuration(Duration? duration) {
@@ -430,41 +479,55 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero // Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Container( child: SizedBox(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
color: colorScheme.surfaceContainerHighest, child: Stack(
child: imageUrl != null fit: StackFit.expand,
? CachedNetworkImage( children: [
imageUrl: imageUrl, Container(
fit: BoxFit.cover, color: colorScheme.surfaceContainerHighest,
// Match source memCacheWidth for smooth Hero animation child: imageUrl != null
memCacheWidth: 256, ? CachedNetworkImage(
memCacheHeight: 256, imageUrl: imageUrl,
fadeInDuration: Duration.zero, fit: BoxFit.cover,
fadeOutDuration: Duration.zero, // Match source memCacheWidth for smooth Hero animation
placeholder: (_, __) => Center( memCacheWidth: 256,
child: Icon( memCacheHeight: 256,
MdiIcons.podcast, fadeInDuration: Duration.zero,
size: coverSize * 0.43, fadeOutDuration: Duration.zero,
color: colorScheme.onSurfaceVariant, placeholder: (_, __) => Center(
), child: Icon(
), MdiIcons.podcast,
errorWidget: (_, __, ___) => Center( size: coverSize * 0.43,
child: Icon( color: colorScheme.onSurfaceVariant,
MdiIcons.podcast, ),
size: coverSize * 0.43, ),
color: colorScheme.onSurfaceVariant, errorWidget: (_, __, ___) => Center(
), child: Icon(
), MdiIcons.podcast,
) size: coverSize * 0.43,
: Center( color: colorScheme.onSurfaceVariant,
child: Icon( ),
MdiIcons.podcast, ),
size: coverSize * 0.43, )
color: colorScheme.onSurfaceVariant, : Center(
), child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.podcast.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.podcast.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
), ),
],
),
), ),
), ),
), ),
@@ -860,8 +923,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
), ),
// Bottom padding for mini player // Bottom padding for mini player
const SliverToBoxAdapter( SliverToBoxAdapter(
child: SizedBox(height: 100), child: SizedBox(height: BottomSpacing.withMiniPlayer),
), ),
], ],
); );
+68
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/player.dart'; import '../models/player.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../widgets/common/empty_state.dart'; import '../widgets/common/empty_state.dart';
import '../widgets/player_picker_sheet.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
class QueueScreen extends StatefulWidget { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>(); final maProvider = context.watch<MusicAssistantProvider>();
@@ -87,6 +150,11 @@ class _QueueScreenState extends State<QueueScreen> {
), ),
centerTitle: true, centerTitle: true,
actions: [ actions: [
IconButton(
icon: const Icon(Icons.swap_horiz_rounded),
tooltip: S.of(context)!.transferQueue,
onPressed: () => _handleTransferQueue(maProvider),
),
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
onPressed: _loadQueue, onPressed: _loadQueue,
+168 -164
View File
@@ -8,7 +8,6 @@ import '../models/media_item.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/database_service.dart'; import '../services/database_service.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../widgets/common/empty_state.dart'; import '../widgets/common/empty_state.dart';
import '../widgets/common/disconnected_state.dart'; import '../widgets/common/disconnected_state.dart';
import '../widgets/artist_avatar.dart'; import '../widgets/artist_avatar.dart';
@@ -22,6 +21,7 @@ import 'playlist_details_screen.dart';
import 'audiobook_detail_screen.dart'; import 'audiobook_detail_screen.dart';
import 'podcast_detail_screen.dart'; import 'podcast_detail_screen.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class SearchScreen extends StatefulWidget { class SearchScreen extends StatefulWidget {
const SearchScreen({super.key}); const SearchScreen({super.key});
@@ -168,6 +168,27 @@ class SearchScreenState extends State<SearchScreen> {
return filters; 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 /// Animate to a specific filter
void _animateToFilter(String filter) { void _animateToFilter(String filter) {
final filters = _getAvailableFilters(); final filters = _getAvailableFilters();
@@ -427,12 +448,21 @@ class SearchScreenState extends State<SearchScreen> {
} }
if (mounted) { 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(() { setState(() {
_searchResults = { _searchResults = combinedResults;
...results,
'radios': allRadios.values.toList(),
'podcasts': allPodcasts.values.toList(),
};
_isSearching = false; _isSearching = false;
_hasSearched = true; _hasSearched = true;
_cachedListItems.clear(); // PERF: Clear cache on new results _cachedListItems.clear(); // PERF: Clear cache on new results
@@ -486,81 +516,97 @@ class SearchScreenState extends State<SearchScreen> {
decoration: const BoxDecoration(), decoration: const BoxDecoration(),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Container( child: Row(
decoration: BoxDecoration( children: [
color: colorScheme.surfaceVariant.withOpacity(0.5), // Search field container
borderRadius: BorderRadius.circular(12), Expanded(
), child: Container(
child: Row( decoration: BoxDecoration(
children: [ color: colorScheme.surfaceVariant.withOpacity(0.5),
// Search field borderRadius: BorderRadius.circular(12),
Expanded( ),
child: Padding( child: TextField(
padding: const EdgeInsets.only(left: 16), controller: _searchController,
child: TextField( focusNode: _focusNode,
controller: _searchController, style: TextStyle(color: colorScheme.onSurface),
focusNode: _focusNode, cursorColor: colorScheme.primary,
style: TextStyle(color: colorScheme.onSurface), textInputAction: TextInputAction.search,
cursorColor: colorScheme.primary, textAlignVertical: TextAlignVertical.center,
textInputAction: TextInputAction.search, decoration: InputDecoration(
textAlignVertical: TextAlignVertical.center, isDense: true,
decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(vertical: 12),
isDense: true, hintText: S.of(context)!.searchMusic,
contentPadding: const EdgeInsets.symmetric(vertical: 12), hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
hintText: S.of(context)!.searchMusic, border: InputBorder.none,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)), prefixIcon: Icon(
border: InputBorder.none, Icons.search_rounded,
suffixIcon: _hasSearchText color: colorScheme.onSurface.withOpacity(0.5),
? 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,
), ),
onChanged: (value) { suffixIcon: _hasSearchText
// PERF: Only rebuild when clear button visibility changes ? IconButton(
final hasText = value.isNotEmpty; icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
if (hasText != _hasSearchText) { onPressed: () {
setState(() { _searchController.clear();
_hasSearchText = hasText; setState(() {
}); _hasSearchText = false;
} _searchResults = {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'podcasts': []};
_onSearchChanged(value); _hasSearched = false;
}, _searchError = null;
onSubmitted: (query) => _performSearch(query), _cachedListItems.clear();
_cachedAvailableFilters = null;
});
},
)
: null,
), ),
), onChanged: (value) {
), // PERF: Only rebuild when clear button visibility changes
// Library-only toggle final hasText = value.isNotEmpty;
Tooltip( if (hasText != _hasSearchText) {
message: S.of(context)!.libraryOnly, setState(() {
child: IconButton( _hasSearchText = hasText;
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);
} }
_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,
),
),
),
),
),
],
), ),
), ),
), ),
@@ -602,7 +648,7 @@ class SearchScreenState extends State<SearchScreen> {
size: 80, size: 80,
color: colorScheme.onBackground.withOpacity(0.3), color: colorScheme.onBackground.withOpacity(0.3),
), ),
const SizedBox(height: 16), Spacing.vGap16,
Text( Text(
S.of(context)!.searchForContent, S.of(context)!.searchForContent,
style: TextStyle( style: TextStyle(
@@ -655,7 +701,7 @@ class SearchScreenState extends State<SearchScreen> {
size: 64, size: 64,
color: colorScheme.error.withOpacity(0.7), color: colorScheme.error.withOpacity(0.7),
), ),
const SizedBox(height: 16), Spacing.vGap16,
Text( Text(
_searchError!, _searchError!,
style: TextStyle( style: TextStyle(
@@ -664,7 +710,7 @@ class SearchScreenState extends State<SearchScreen> {
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 16), Spacing.vGap16,
FilledButton.tonal( FilledButton.tonal(
onPressed: () => _performSearch(_searchController.text), onPressed: () => _performSearch(_searchController.text),
child: Text(S.of(context)!.retry), child: Text(S.of(context)!.retry),
@@ -785,7 +831,7 @@ class SearchScreenState extends State<SearchScreen> {
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
colorScheme.background, colorScheme.background,
colorScheme.surface.withOpacity(0), colorScheme.background.withOpacity(0),
], ],
), ),
), ),
@@ -1222,7 +1268,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(Radii.xxl),
), ),
), ),
child: Icon( child: Icon(
@@ -1438,7 +1484,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(Radii.xxl),
), ),
), ),
child: Icon( child: Icon(
@@ -1632,7 +1678,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(Radii.xxl),
), ),
), ),
child: Icon( child: Icon(
@@ -1997,7 +2043,7 @@ class SearchScreenState extends State<SearchScreen> {
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(Radii.xxl),
), ),
), ),
child: Icon( child: Icon(
@@ -2368,7 +2414,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play radio station: $e')), SnackBar(content: Text(S.of(context)!.failedToPlayRadioStation(e.toString()))),
); );
} }
} }
@@ -2426,13 +2472,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showRadioOnMenu(Track track) { void _showRadioOnMenu(Track track) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
try { try {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
@@ -2453,9 +2494,7 @@ class SearchScreenState extends State<SearchScreen> {
} }
} }
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
/// Add track to queue on current player (1 tap) /// Add track to queue on current player (1 tap)
@@ -2565,7 +2604,7 @@ class SearchScreenState extends State<SearchScreen> {
_logger.log('Error toggling favorite: $e'); _logger.log('Error toggling favorite: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update favorite: $e')), SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
); );
} }
} }
@@ -2616,7 +2655,7 @@ class SearchScreenState extends State<SearchScreen> {
// Item is library-only, can't add // Item is library-only, can't add
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Item is already in library')), SnackBar(content: Text(S.of(context)!.itemAlreadyInLibrary)),
); );
} }
return; return;
@@ -2652,7 +2691,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) { if (mounted) {
setState(() {}); setState(() {});
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to add to library')), SnackBar(content: Text(S.of(context)!.failedToAddToLibrary)),
); );
} }
} }
@@ -2679,7 +2718,7 @@ class SearchScreenState extends State<SearchScreen> {
if (libraryItemId == null) { if (libraryItemId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot find library ID for removal')), SnackBar(content: Text(S.of(context)!.cannotFindLibraryId)),
); );
return; return;
} }
@@ -2711,7 +2750,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) { if (mounted) {
setState(() {}); setState(() {});
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to remove from library')), SnackBar(content: Text(S.of(context)!.failedToRemoveFromLibrary)),
); );
} }
} }
@@ -2855,7 +2894,7 @@ class SearchScreenState extends State<SearchScreen> {
_logger.log('Error toggling artist favorite: $e'); _logger.log('Error toggling artist favorite: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update favorite: $e')), SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
); );
} }
} }
@@ -2886,7 +2925,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play album: $e')), SnackBar(content: Text(S.of(context)!.failedToPlayAlbum(e.toString()))),
); );
} }
} }
@@ -2896,13 +2935,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showAlbumPlayOnMenu(Album album) { void _showAlbumPlayOnMenu(Album album) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
try { try {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
@@ -2910,7 +2944,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Playing ${album.name} on ${player.name}'), content: Text(S.of(context)!.playingOnPlayer(album.name, player.name)),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -2918,14 +2952,12 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play album: $e')), SnackBar(content: Text(S.of(context)!.failedToPlayAlbum(e.toString()))),
); );
} }
} }
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
/// Add album to queue /// Add album to queue
@@ -3034,7 +3066,7 @@ class SearchScreenState extends State<SearchScreen> {
_logger.log('Error toggling album favorite: $e'); _logger.log('Error toggling album favorite: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update favorite: $e')), SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
); );
} }
} }
@@ -3065,7 +3097,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play playlist: $e')), SnackBar(content: Text(S.of(context)!.failedToPlayPlaylist(e.toString()))),
); );
} }
} }
@@ -3075,13 +3107,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showPlaylistPlayOnMenu(Playlist playlist) { void _showPlaylistPlayOnMenu(Playlist playlist) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlayPlaylist,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
try { try {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
@@ -3089,7 +3116,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Playing ${playlist.name} on ${player.name}'), content: Text(S.of(context)!.playingOnPlayer(playlist.name, player.name)),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -3097,14 +3124,12 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play playlist: $e')), SnackBar(content: Text(S.of(context)!.failedToPlayPlaylist(e.toString()))),
); );
} }
} }
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
/// Play audiobook on current player /// Play audiobook on current player
@@ -3124,7 +3149,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Playing ${audiobook.name}'), content: Text(S.of(context)!.playing(audiobook.name)),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -3132,7 +3157,7 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play audiobook: $e')), SnackBar(content: Text(S.of(context)!.failedToPlayAudiobook(e.toString()))),
); );
} }
} }
@@ -3142,13 +3167,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showAudiobookPlayOnMenu(Audiobook audiobook) { void _showAudiobookPlayOnMenu(Audiobook audiobook) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForAudiobook,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
try { try {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
@@ -3156,7 +3176,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Playing ${audiobook.name} on ${player.name}'), content: Text(S.of(context)!.playingOnPlayer(audiobook.name, player.name)),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -3164,14 +3184,12 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play audiobook: $e')), SnackBar(content: Text(S.of(context)!.failedToPlayAudiobook(e.toString()))),
); );
} }
} }
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
/// Toggle audiobook favorite status /// Toggle audiobook favorite status
@@ -3249,7 +3267,7 @@ class SearchScreenState extends State<SearchScreen> {
_logger.log('Error toggling audiobook favorite: $e'); _logger.log('Error toggling audiobook favorite: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update favorite: $e')), SnackBar(content: Text(S.of(context)!.failedToUpdateFavorite(e.toString()))),
); );
} }
} }
@@ -3259,13 +3277,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showRadioPlayOnMenu(MediaItem radio) { void _showRadioPlayOnMenu(MediaItem radio) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForRadio,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
try { try {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
@@ -3273,7 +3286,7 @@ class SearchScreenState extends State<SearchScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Playing ${radio.name} on ${player.name}'), content: Text(S.of(context)!.playingOnPlayer(radio.name, player.name)),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
@@ -3281,14 +3294,12 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play radio station: $e')), SnackBar(content: Text(S.of(context)!.failedToPlayRadioStation(e.toString()))),
); );
} }
} }
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
/// Play podcast (navigate to detail screen where episodes can be played) /// Play podcast (navigate to detail screen where episodes can be played)
@@ -3316,13 +3327,8 @@ class SearchScreenState extends State<SearchScreen> {
void _showPodcastPlayOnMenu(MediaItem podcast) { void _showPodcastPlayOnMenu(MediaItem podcast) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerToPlay,
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async { onPlayerSelected: (player) async {
// Select the player, then navigate to podcast detail // Select the player, then navigate to podcast detail
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
@@ -3342,9 +3348,7 @@ class SearchScreenState extends State<SearchScreen> {
); );
} }
}, },
).whenComplete(() { );
GlobalPlayerOverlay.showPlayer();
});
} }
String _formatDuration(Duration duration) { String _formatDuration(Duration duration) {
+49 -110
View File
@@ -38,17 +38,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showDiscoverSeries = false; bool _showDiscoverSeries = false;
// Home row order // Home row order
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder); List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Audiobook libraries
List<Map<String, String>> _discoveredLibraries = [];
Map<String, bool> _libraryEnabled = {};
// Player settings // Player settings
bool _preferLocalPlayer = false; bool _preferLocalPlayer = false;
bool _smartSortPlayers = false; bool _smartSortPlayers = false;
bool _disableAutoSwitch = false;
bool _volumePrecisionMode = true; bool _volumePrecisionMode = true;
// Hint settings // Hint settings
bool _showHints = true; bool _showHints = true;
// Library settings // Display settings
bool _showOnlyArtistsWithAlbums = false; bool _showProviderIcons = true;
@override @override
void initState() { void initState() {
@@ -86,26 +84,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load home row order // Load home row order
final rowOrder = await SettingsService.getHomeRowOrder(); 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 // Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer(); final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers(); final smartSort = await SettingsService.getSmartSortPlayers();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final volumePrecision = await SettingsService.getVolumePrecisionMode(); final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings // Load hint settings
final showHints = await SettingsService.getShowHints(); final showHints = await SettingsService.getShowHints();
// Load library settings // Load display settings
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums(); final showProviderIcons = await SettingsService.getShowProviderIcons();
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -122,13 +111,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showDiscoverAudiobooks = showDiscAudiobooks; _showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries; _showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder; _homeRowOrder = rowOrder;
_discoveredLibraries = discovered;
_libraryEnabled = libraryEnabled;
_preferLocalPlayer = preferLocal; _preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort; _smartSortPlayers = smartSort;
_disableAutoSwitch = disableAutoSwitch;
_volumePrecisionMode = volumePrecision; _volumePrecisionMode = volumePrecision;
_showHints = showHints; _showHints = showHints;
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums; _showProviderIcons = showProviderIcons;
}); });
} }
} }
@@ -649,7 +637,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [ children: [
RadioListTile<String?>( RadioListTile<String?>(
title: Text(S.of(context)!.system), title: Text(S.of(context)!.system),
subtitle: const Text('Auto'), subtitle: Text(S.of(context)!.auto),
value: null, value: null,
groupValue: localeProvider.locale?.languageCode, groupValue: localeProvider.locale?.languageCode,
onChanged: (value) { onChanged: (value) {
@@ -658,7 +646,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
RadioListTile<String?>( RadioListTile<String?>(
title: const Text('English'), title: Text(S.of(context)!.english),
value: 'en', value: 'en',
groupValue: localeProvider.locale?.languageCode, groupValue: localeProvider.locale?.languageCode,
onChanged: (value) { onChanged: (value) {
@@ -667,7 +655,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
RadioListTile<String?>( RadioListTile<String?>(
title: const Text('Deutsch'), title: Text(S.of(context)!.german),
value: 'de', value: 'de',
groupValue: localeProvider.locale?.languageCode, groupValue: localeProvider.locale?.languageCode,
onChanged: (value) { onChanged: (value) {
@@ -676,7 +664,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
RadioListTile<String?>( RadioListTile<String?>(
title: const Text('Español'), title: Text(S.of(context)!.spanish),
value: 'es', value: 'es',
groupValue: localeProvider.locale?.languageCode, groupValue: localeProvider.locale?.languageCode,
onChanged: (value) { onChanged: (value) {
@@ -685,7 +673,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
RadioListTile<String?>( RadioListTile<String?>(
title: const Text('Français'), title: Text(S.of(context)!.french),
value: 'fr', value: 'fr',
groupValue: localeProvider.locale?.languageCode, groupValue: localeProvider.locale?.languageCode,
onChanged: (value) { onChanged: (value) {
@@ -767,6 +755,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
const SizedBox(height: 8), 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(
S.of(context)!.disableAutoSwitch,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.disableAutoSwitchDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _disableAutoSwitch,
onChanged: (value) {
setState(() => _disableAutoSwitch = value);
SettingsService.setDisableAutoSwitch(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -832,17 +845,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 24),
// Library section // Display section
Text( Text(
S.of(context)!.library, S.of(context)!.display,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -851,24 +865,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
child: SwitchListTile( child: SwitchListTile(
title: Text( title: Text(
'Show only artists with albums', S.of(context)!.showProviderIcons,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
), ),
subtitle: Text( subtitle: Text(
'Hide artists that have no albums in your library', S.of(context)!.showProviderIconsDescription,
style: TextStyle( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 12,
), ),
), ),
value: _showOnlyArtistsWithAlbums, value: _showProviderIcons,
onChanged: (value) async { onChanged: (value) {
setState(() => _showOnlyArtistsWithAlbums = value); setState(() => _showProviderIcons = value);
await SettingsService.setShowOnlyArtistsWithAlbums(value); SettingsService.setShowProviderIcons(value);
// Force sync library to apply the new filter at API level
if (mounted) {
context.read<MusicAssistantProvider>().forceLibrarySync();
}
}, },
activeColor: colorScheme.primary, activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
@@ -1053,76 +1062,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32), 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( SizedBox(
width: double.infinity, width: double.infinity,
height: 50, height: 50,
+14
View File
@@ -213,6 +213,20 @@ class CacheService {
_logger.log('🗑️ Home artist caches invalidated'); _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 // DETAIL SCREEN CACHING
// ============================================================================ // ============================================================================
+33
View File
@@ -107,15 +107,18 @@ class DatabaseService {
// ============================================ // ============================================
/// Cache an item with JSON serialization /// Cache an item with JSON serialization
/// [sourceProvider] - optional provider instance ID that provided this item (for client-side filtering)
Future<void> cacheItem<T>({ Future<void> cacheItem<T>({
required String itemType, required String itemType,
required String itemId, required String itemId,
required Map<String, dynamic> data, required Map<String, dynamic> data,
String? sourceProvider,
}) { }) {
return db.cacheItem( return db.cacheItem(
itemType: itemType, itemType: itemType,
itemId: itemId, itemId: itemId,
data: jsonEncode(data), data: jsonEncode(data),
sourceProvider: sourceProvider,
); );
} }
@@ -132,6 +135,29 @@ class DatabaseService {
}).where((item) => item.isNotEmpty).toList(); }).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 /// Get a single cached item
Future<Map<String, dynamic>?> getCachedItem(String itemType, String itemId) async { Future<Map<String, dynamic>?> getCachedItem(String itemType, String itemId) async {
final item = await db.getCachedItem(itemType, itemId); final item = await db.getCachedItem(itemType, itemId);
@@ -167,6 +193,13 @@ class DatabaseService {
return db.markItemsDeleted(itemType, [itemId]); return db.markItemsDeleted(itemType, [itemId]);
} }
/// Batch cache multiple items efficiently
/// Uses drift's batch API for much better performance on large datasets.
/// Items are automatically chunked into batches of 500.
Future<void> batchCacheItems(List<BatchCacheItem> items) {
return db.batchCacheItems(items);
}
// ============================================ // ============================================
// Player Cache Convenience Methods // Player Cache Convenience Methods
// ============================================ // ============================================
+451 -94
View File
@@ -2,14 +2,19 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'debug_logger.dart'; import 'debug_logger.dart';
import 'settings_service.dart'; import 'settings_service.dart';
import '../utils/lru_cache.dart';
class MetadataService { class MetadataService {
static final _logger = DebugLogger(); static final _logger = DebugLogger();
// Cache to avoid repeated API calls for the same artist/album
static final Map<String, String> _cache = {};
// Cache for artist images // LRU caches to avoid repeated API calls and prevent unbounded memory growth
static final Map<String, String?> _artistImageCache = {}; // Using NullableLruCache for caches that store null values (failed lookups)
/// Cache for artist/album descriptions (100 entries)
static final LruCache<String, String> _descriptionCache = LruCache(maxSize: 100);
/// Cache for artist images - includes null entries for failed lookups (200 entries)
static final NullableLruCache<String, String> _artistImageCache = NullableLruCache(maxSize: 200);
/// Fetches artist biography/description with fallback chain: /// Fetches artist biography/description with fallback chain:
/// 1. Music Assistant metadata (passed in) /// 1. Music Assistant metadata (passed in)
@@ -34,8 +39,8 @@ class MetadataService {
// Check cache // Check cache
final cacheKey = 'artist:$artistName'; final cacheKey = 'artist:$artistName';
if (_cache.containsKey(cacheKey)) { if (_descriptionCache.containsKey(cacheKey)) {
return _cache[cacheKey]; return _descriptionCache[cacheKey];
} }
// Try Last.fm API // Try Last.fm API
@@ -43,7 +48,7 @@ class MetadataService {
if (lastFmKey != null && lastFmKey.isNotEmpty) { if (lastFmKey != null && lastFmKey.isNotEmpty) {
final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey); final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey);
if (lastFmDesc != null) { if (lastFmDesc != null) {
_cache[cacheKey] = lastFmDesc; _descriptionCache[cacheKey] = lastFmDesc;
return lastFmDesc; return lastFmDesc;
} }
} }
@@ -53,7 +58,7 @@ class MetadataService {
if (audioDbKey != null && audioDbKey.isNotEmpty) { if (audioDbKey != null && audioDbKey.isNotEmpty) {
final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey); final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey);
if (audioDbDesc != null) { if (audioDbDesc != null) {
_cache[cacheKey] = audioDbDesc; _descriptionCache[cacheKey] = audioDbDesc;
return audioDbDesc; return audioDbDesc;
} }
} }
@@ -83,8 +88,8 @@ class MetadataService {
// Check cache // Check cache
final cacheKey = 'album:$artistName:$albumName'; final cacheKey = 'album:$artistName:$albumName';
if (_cache.containsKey(cacheKey)) { if (_descriptionCache.containsKey(cacheKey)) {
return _cache[cacheKey]; return _descriptionCache[cacheKey];
} }
// Try Last.fm API (TheAudioDB doesn't have good album info) // Try Last.fm API (TheAudioDB doesn't have good album info)
@@ -92,7 +97,7 @@ class MetadataService {
if (lastFmKey != null && lastFmKey.isNotEmpty) { if (lastFmKey != null && lastFmKey.isNotEmpty) {
final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey); final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey);
if (lastFmDesc != null) { if (lastFmDesc != null) {
_cache[cacheKey] = lastFmDesc; _descriptionCache[cacheKey] = lastFmDesc;
return lastFmDesc; return lastFmDesc;
} }
} }
@@ -251,11 +256,11 @@ class MetadataService {
return null; return null;
} }
// Cache for album images /// Cache for album images - includes null entries for failed lookups (200 entries)
static final Map<String, String?> _albumImageCache = {}; static final NullableLruCache<String, String> _albumImageCache = NullableLruCache(maxSize: 200);
// Cache for author images (audiobooks) /// Cache for author images (audiobooks) - includes null entries for failed lookups (100 entries)
static final Map<String, String?> _authorImageCache = {}; static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 100);
/// Fetches album cover URL from Deezer (free, no API key required) /// Fetches album cover URL from Deezer (free, no API key required)
/// Returns the image URL if found, null otherwise /// Returns the image URL if found, null otherwise
@@ -326,120 +331,301 @@ class MetadataService {
return null; return null;
} }
/// Fetches author image URL from multiple sources /// Generate name variations for fuzzy matching
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia /// Returns a list of name variations to try, in priority order
/// Returns the image URL if found, null otherwise static List<String> _generateNameVariations(String authorName) {
static Future<String?> getAuthorImageUrl(String authorName) async { final variations = <String>[];
// Check cache first final original = authorName.trim();
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) { // Always try original first
return _authorImageCache[cacheKey]; 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 { try {
final audnexusUri = Uri.https( // Step 1: Search for author to get ASIN
'api.audnex.us', final searchUri = Uri.https('api.audnex.us', '/authors', {'name': name});
'/authors', final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
{'name': authorName},
);
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) { for (final result in searchData) {
final data = json.decode(audnexusResponse.body); final resultName = (result['name'] as String?)?.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
// Audnexus returns an array of authors final asin = result['asin'] as String?;
if (data is List && data.isNotEmpty) { if (asin != null) {
final author = data[0]; if (resultName == nameLower) {
final imageUrl = author['image'] as String?; bestAsin = asin;
if (imageUrl != null && imageUrl.isNotEmpty) { break; // Exact match found
_authorImageCache[cacheKey] = imageUrl; }
return imageUrl; 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) { } 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 { try {
final uri = Uri.https( final uri = Uri.https('openlibrary.org', '/search/authors.json', {
'openlibrary.org', 'q': name,
'/search/authors.json', 'limit': '1',
{ });
'q': authorName,
'limit': '1',
},
);
final response = await http.get(uri).timeout(const Duration(seconds: 5)); final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = json.decode(response.body); final data = json.decode(response.body);
final authors = data['docs'] as List?; final authors = data['docs'] as List?;
if (authors != null && authors.isNotEmpty) { if (authors != null && authors.isNotEmpty) {
final author = authors[0]; final authorKey = authors[0]['key'] as String?;
final authorKey = author['key'] as String?;
if (authorKey != null) { 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 olid = authorKey.replaceAll('/authors/', '');
final imageUrl = 'https://covers.openlibrary.org/a/olid/$olid-L.jpg'; 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 { try {
final imageCheck = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3)); final check = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
if (imageCheck.statusCode == 200) { // Accept 200 (direct), 301/302 (redirect to archive.org), or 307/308 (temporary redirect)
_authorImageCache[cacheKey] = imageUrl; if (check.statusCode == 200 || check.statusCode == 301 ||
check.statusCode == 302 || check.statusCode == 307 || check.statusCode == 308) {
return imageUrl; return imageUrl;
} }
} catch (_) { } catch (_) {}
// Image doesn't exist, continue to cache null }
}
}
} 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) { } 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 { try {
// Search for author on Wikipedia // Search for author - "writer author" suffix helps find the right page
final searchUri = Uri.https( final searchUri = Uri.https('en.wikipedia.org', '/w/api.php', {
'en.wikipedia.org', 'action': 'query',
'/w/api.php', 'list': 'search',
{ 'srsearch': '$name writer author',
'action': 'query', 'srlimit': '1',
'list': 'search', 'format': 'json',
'srsearch': '$authorName writer', });
'srlimit': '1',
'format': 'json',
},
);
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5)); final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) { if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body); final searchData = json.decode(searchResponse.body);
final searchResults = searchData['query']?['search'] as List?; final results = searchData['query']?['search'] as List?;
if (searchResults != null && searchResults.isNotEmpty) { if (results != null && results.isNotEmpty) {
final pageTitle = searchResults[0]['title'] as String?; final pageTitle = results[0]['title'] as String?;
if (pageTitle != null) { if (pageTitle != null) {
// Get page image // Get page image
final imageUri = Uri.https( final imageUri = Uri.https('en.wikipedia.org', '/w/api.php', {
'en.wikipedia.org', 'action': 'query',
'/w/api.php', 'titles': pageTitle,
{ 'prop': 'pageimages',
'action': 'query', 'pithumbsize': '500',
'titles': pageTitle, 'format': 'json',
'prop': 'pageimages', });
'pithumbsize': '500',
'format': 'json',
},
);
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5)); final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
if (imageResponse.statusCode == 200) { if (imageResponse.statusCode == 200) {
@@ -450,7 +636,6 @@ class MetadataService {
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?; final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
final imageUrl = thumbnail?['source'] as String?; final imageUrl = thumbnail?['source'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) { if (imageUrl != null && imageUrl.isNotEmpty) {
_authorImageCache[cacheKey] = imageUrl;
return imageUrl; return imageUrl;
} }
} }
@@ -459,7 +644,100 @@ class MetadataService {
} }
} }
} catch (e) { } 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 // Cache the null result to avoid repeated failed lookups
@@ -467,11 +745,90 @@ class MetadataService {
return null; return null;
} }
/// Clears the metadata cache /// Cache for lyrics - uses bounded Map with manual size limiting (500 entries max)
/// Stores (plainLyrics, syncedLyrics) tuples or null for failed lookups
static final Map<String, (String?, String?)?> _lyricsCache = {};
static const int _lyricsCacheMaxSize = 500;
/// Fetches lyrics from LRCLIB (free, no API key required)
/// Returns a tuple of (plainLyrics, syncedLyrics) or null if not found
/// LRCLIB provides both synced (LRC format) and plain lyrics
static Future<(String?, String?)?> getTrackLyrics({
required String trackName,
required String artistName,
String? albumName,
int? durationSeconds,
}) async {
// Build cache key
final cacheKey = 'lyrics:$artistName:$trackName';
if (_lyricsCache.containsKey(cacheKey)) {
return _lyricsCache[cacheKey];
}
// Try LRCLIB API
try {
final params = <String, String>{
'track_name': trackName,
'artist_name': artistName,
};
if (albumName != null && albumName.isNotEmpty) {
params['album_name'] = albumName;
}
if (durationSeconds != null && durationSeconds > 0) {
params['duration'] = durationSeconds.toString();
}
final uri = Uri.https('lrclib.net', '/api/get', params);
_logger.log('🎤 Fetching lyrics from LRCLIB: $trackName by $artistName');
final response = await http.get(
uri,
headers: {
'User-Agent': 'Ensemble Music Player (https://github.com/ensemble-app)',
},
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final plainLyrics = data['plainLyrics'] as String?;
final syncedLyrics = data['syncedLyrics'] as String?;
if ((plainLyrics != null && plainLyrics.isNotEmpty) ||
(syncedLyrics != null && syncedLyrics.isNotEmpty)) {
_logger.log('🎤 LRCLIB found lyrics: plain=${plainLyrics?.length ?? 0} chars, synced=${syncedLyrics?.length ?? 0} chars');
final result = (plainLyrics, syncedLyrics);
_evictLyricsCacheIfNeeded();
_lyricsCache[cacheKey] = result;
return result;
}
} else if (response.statusCode == 404) {
_logger.log('🎤 LRCLIB: No lyrics found for $trackName');
} else {
_logger.log('🎤 LRCLIB error: ${response.statusCode}');
}
} catch (e) {
_logger.warning('LRCLIB API error: $e', context: 'Metadata');
}
// Cache the null result to avoid repeated failed lookups
_evictLyricsCacheIfNeeded();
_lyricsCache[cacheKey] = null;
return null;
}
/// Evict oldest entries from lyrics cache if over max size
static void _evictLyricsCacheIfNeeded() {
while (_lyricsCache.length >= _lyricsCacheMaxSize) {
_lyricsCache.remove(_lyricsCache.keys.first);
}
}
/// Clears all metadata caches
static void clearCache() { static void clearCache() {
_cache.clear(); _descriptionCache.clear();
_artistImageCache.clear(); _artistImageCache.clear();
_albumImageCache.clear(); _albumImageCache.clear();
_authorImageCache.clear(); _authorImageCache.clear();
_lyricsCache.clear();
} }
} }
+326 -66
View File
@@ -8,6 +8,8 @@ import '../constants/network.dart';
import '../constants/timings.dart' show Timings, LibraryConstants; import '../constants/timings.dart' show Timings, LibraryConstants;
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../models/player.dart'; import '../models/player.dart';
import '../models/provider_instance.dart';
import '../models/provider_manifest.dart';
import 'debug_logger.dart'; import 'debug_logger.dart';
import 'settings_service.dart'; import 'settings_service.dart';
import 'device_id_service.dart'; import 'device_id_service.dart';
@@ -59,6 +61,10 @@ class MusicAssistantAPI {
int? get schemaVersion => _schemaVersion; int? get schemaVersion => _schemaVersion;
Map<String, dynamic>? get serverInfo => _serverInfo; Map<String, dynamic>? get serverInfo => _serverInfo;
// Provider manifests (contains icons)
final Map<String, ProviderManifest> _providerManifests = {};
Map<String, ProviderManifest> get providerManifests => _providerManifests;
MusicAssistantAPI(this.serverUrl, this.authManager); MusicAssistantAPI(this.serverUrl, this.authManager);
// Guard to prevent multiple simultaneous connection attempts // Guard to prevent multiple simultaneous connection attempts
@@ -79,13 +85,15 @@ class MusicAssistantAPI {
return; return;
} }
// If connection is in progress, wait for it instead of starting another // Atomic check-and-set: create new completer first, then check if one exists
// This ensures no async operations happen between check and set
final newCompleter = Completer<void>();
if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) { if (_connectionInProgress != null && !_connectionInProgress!.isCompleted) {
_logger.log('Connection already in progress, waiting...'); _logger.log('Connection already in progress, waiting...');
return _connectionInProgress!.future; return _connectionInProgress!.future;
} }
// Set immediately before any async operations to close the race window
_connectionInProgress = Completer<void>(); _connectionInProgress = newCompleter;
try { try {
_updateConnectionState(MAConnectionState.connecting); _updateConnectionState(MAConnectionState.connecting);
@@ -344,6 +352,14 @@ class MusicAssistantAPI {
} }
} }
// Prevent memory leaks by limiting pending requests
if (_pendingRequests.length >= NetworkConstants.maxPendingRequests) {
throw Exception(
'Too many pending requests (${_pendingRequests.length}). '
'Please wait for existing requests to complete.',
);
}
final messageId = _uuid.v4(); final messageId = _uuid.v4();
final completer = Completer<Map<String, dynamic>>(); final completer = Completer<Map<String, dynamic>>();
_pendingRequests[messageId] = completer; _pendingRequests[messageId] = completer;
@@ -376,23 +392,31 @@ class MusicAssistantAPI {
String? search, String? search,
bool? favoriteOnly, bool? favoriteOnly,
bool albumArtistsOnly = true, bool albumArtistsOnly = true,
String? orderBy,
List<String>? providerInstanceIds,
}) async { }) async {
try { 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( final response = await _sendCommand(
'music/artists/library_items', 'music/artists/library_items',
args: { args: 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,
},
); );
final items = response['result'] as List<dynamic>?; final items = response['result'] as List<dynamic>?;
if (items == null) { if (items == null) {
return []; return [];
} }
_logger.log('🔍 API getArtists returned ${items.length} items');
return items return items
.map((item) => Artist.fromJson(item as Map<String, dynamic>)) .map((item) => Artist.fromJson(item as Map<String, dynamic>))
.toList(); .toList();
@@ -402,7 +426,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 { try {
final response = await _sendCommand( final response = await _sendCommand(
'music/artists/library_items', 'music/artists/library_items',
@@ -410,6 +438,8 @@ class MusicAssistantAPI {
'limit': limit, 'limit': limit,
'order_by': 'random', 'order_by': 'random',
'album_artists_only': albumArtistsOnly, 'album_artists_only': albumArtistsOnly,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
}, },
); );
@@ -431,6 +461,8 @@ class MusicAssistantAPI {
String? search, String? search,
bool? favoriteOnly, bool? favoriteOnly,
String? artistId, String? artistId,
String? orderBy,
List<String>? providerInstanceIds,
}) async { }) async {
try { try {
final args = <String, dynamic>{ final args = <String, dynamic>{
@@ -439,7 +471,11 @@ class MusicAssistantAPI {
if (search != null) 'search': search, if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly, if (favoriteOnly != null) 'favorite': favoriteOnly,
if (artistId != null) 'artist_id': artistId, 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( final response = await _sendCommand(
'music/albums/library_items', 'music/albums/library_items',
@@ -448,6 +484,7 @@ class MusicAssistantAPI {
final items = response['result'] as List<dynamic>?; final items = response['result'] as List<dynamic>?;
if (items == null) return []; if (items == null) return [];
_logger.log('🔍 API getAlbums returned ${items.length} items');
return items return items
.map((item) => Album.fromJson(item as Map<String, dynamic>)) .map((item) => Album.fromJson(item as Map<String, dynamic>))
@@ -465,6 +502,8 @@ class MusicAssistantAPI {
bool? favoriteOnly, bool? favoriteOnly,
String? artistId, String? artistId,
String? albumId, String? albumId,
String? orderBy,
List<String>? providerInstanceIds,
}) async { }) async {
try { try {
final response = await _sendCommand( final response = await _sendCommand(
@@ -476,6 +515,9 @@ class MusicAssistantAPI {
if (favoriteOnly != null) 'favorite': favoriteOnly, if (favoriteOnly != null) 'favorite': favoriteOnly,
if (artistId != null) 'artist': artistId, if (artistId != null) 'artist': artistId,
if (albumId != null) 'album': albumId, if (albumId != null) 'album': albumId,
if (orderBy != null) 'order_by': orderBy,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
}, },
); );
@@ -496,6 +538,7 @@ class MusicAssistantAPI {
int? limit, int? limit,
int? offset, int? offset,
bool? favoriteOnly, bool? favoriteOnly,
String? orderBy,
}) async { }) async {
try { try {
final response = await _sendCommand( final response = await _sendCommand(
@@ -504,6 +547,7 @@ class MusicAssistantAPI {
if (limit != null) 'limit': limit, if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset, if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly, if (favoriteOnly != null) 'favorite': favoriteOnly,
if (orderBy != null) 'order_by': orderBy,
}, },
); );
@@ -555,6 +599,7 @@ class MusicAssistantAPI {
int? limit, int? limit,
int? offset, int? offset,
bool? favoriteOnly, bool? favoriteOnly,
String? orderBy,
}) async { }) async {
try { try {
final response = await _sendCommand( final response = await _sendCommand(
@@ -563,6 +608,7 @@ class MusicAssistantAPI {
if (limit != null) 'limit': limit, if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset, if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly, if (favoriteOnly != null) 'favorite': favoriteOnly,
if (orderBy != null) 'order_by': orderBy,
}, },
); );
@@ -719,48 +765,114 @@ class MusicAssistantAPI {
String? search, String? search,
bool? favoriteOnly, bool? favoriteOnly,
String? authorId, String? authorId,
List<String>? providerInstanceIds,
}) async { }) async {
try { 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(); 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) { if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly); for (final libPath in enabledLibraries) {
} final providerEnd = libPath.indexOf('://');
if (providerEnd != -1) {
// Otherwise use the faster MA API (returns all audiobooks) absProviderIds.add(libPath.substring(0, providerEnd));
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
} }
} }
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 specific non-ABS providers to query
// NOTE: Do NOT call when ABS filtering is active but no non-ABS providers exist,
// as library_items API would return all audiobooks bypassing the library filter
final shouldFetchFromApi = enabledLibraries == null ||
enabledLibraries.isEmpty ||
(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) { } catch (e, stack) {
_logger.log('Error getting audiobooks: $e'); _logger.log('Error getting audiobooks: $e');
_logger.log('Stack: $stack'); _logger.log('Stack: $stack');
@@ -988,6 +1100,51 @@ class MusicAssistantAPI {
} }
} }
/// Get lyrics for a track
/// Returns the lyrics text if available, null otherwise
/// Uses the dedicated metadata/get_track_lyrics endpoint
Future<String?> getTrackLyrics(Map<String, dynamic> track) async {
try {
_logger.log('🎤 Getting lyrics for track: ${track['name']}');
// Use the dedicated lyrics endpoint
final response = await _sendCommand(
'metadata/get_track_lyrics',
args: {
'track': track,
},
);
if (response.containsKey('error_code')) {
_logger.log('🎤 Error getting lyrics: ${response['error_code']}');
return null;
}
// Result is a tuple: [plain_lyrics, synced_lyrics]
// We prefer synced lyrics if available, otherwise plain
final result = response['result'];
if (result is List && result.length >= 2) {
final plainLyrics = result[0] as String?;
final syncedLyrics = result[1] as String?;
if (syncedLyrics != null && syncedLyrics.isNotEmpty) {
_logger.log('🎤 Found synced lyrics: ${syncedLyrics.length} chars');
return syncedLyrics;
}
if (plainLyrics != null && plainLyrics.isNotEmpty) {
_logger.log('🎤 Found plain lyrics: ${plainLyrics.length} chars');
return plainLyrics;
}
}
_logger.log('🎤 No lyrics found in result: $result');
return null;
} catch (e) {
_logger.log('🎤 Error getting lyrics: $e');
return null;
}
}
/// Browse Music Assistant content by path /// Browse Music Assistant content by path
/// Returns a list of browse items (folders or media items) /// Returns a list of browse items (folders or media items)
Future<List<dynamic>> browse(String? path) async { Future<List<dynamic>> browse(String? path) async {
@@ -1082,7 +1239,7 @@ class MusicAssistantAPI {
for (final item in providerRoot) { for (final item in providerRoot) {
final itemMap = item as Map<String, dynamic>; final itemMap = item as Map<String, dynamic>;
final path = itemMap['path'] as String? ?? ''; 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 != '..') { if (path != 'root' && name != '..') {
discoveredLibraries.add({'path': path, 'name': name}); discoveredLibraries.add({'path': path, 'name': name});
} }
@@ -1101,7 +1258,7 @@ class MusicAssistantAPI {
for (final libraryItem in providerRoot) { for (final libraryItem in providerRoot) {
final libraryMap = libraryItem as Map<String, dynamic>; final libraryMap = libraryItem as Map<String, dynamic>;
final libraryPath = libraryMap['path'] as String? ?? ''; 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 // Skip "root" (parent navigation) item
if (libraryPath == 'root' || libraryName == '..') continue; if (libraryPath == 'root' || libraryName == '..') continue;
@@ -1121,7 +1278,7 @@ class MusicAssistantAPI {
// Log library contents to find series // Log library contents to find series
for (final item in libraryContents) { for (final item in libraryContents) {
final itemMap = item as Map<String, dynamic>; 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? ?? ''; final path = itemMap['path'] as String? ?? '';
_logger.log('📚 Library item: name=$name, path=$path'); _logger.log('📚 Library item: name=$name, path=$path');
} }
@@ -1129,10 +1286,11 @@ class MusicAssistantAPI {
// Look for "Series" folder in the library // Look for "Series" folder in the library
for (final item in libraryContents) { for (final item in libraryContents) {
final itemMap = item as Map<String, dynamic>; 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? ?? ''; 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'); _logger.log('📚 Found series folder: $path');
// Browse the series folder // Browse the series folder
@@ -1141,13 +1299,15 @@ class MusicAssistantAPI {
for (final seriesItem in seriesItems) { for (final seriesItem in seriesItems) {
final seriesMap = seriesItem as Map<String, dynamic>; 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 // 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) // Skip actual audiobook items (we only want series folders)
final mediaType = seriesMap['media_type'] as String?; final mediaType = seriesMap['media_type'] as String?;
if (mediaType == 'audiobook' || mediaType == 'track') continue; 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)); allSeries.add(AudiobookSeries.fromJson(seriesMap));
} }
break; // Found series in this library, move to next library break; // Found series in this library, move to next library
@@ -1335,13 +1495,18 @@ class MusicAssistantAPI {
} }
/// Get random albums /// Get random albums
Future<List<Album>> getRandomAlbums({int limit = 10}) async { Future<List<Album>> getRandomAlbums({
int limit = 10,
List<String>? providerInstanceIds,
}) async {
try { try {
final response = await _sendCommand( final response = await _sendCommand(
'music/albums/library_items', 'music/albums/library_items',
args: { args: {
'limit': limit, 'limit': limit,
'order_by': 'random', 'order_by': 'random',
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
}, },
); );
@@ -1457,6 +1622,8 @@ class MusicAssistantAPI {
int? offset, int? offset,
String? search, String? search,
bool? favoriteOnly, bool? favoriteOnly,
String? orderBy,
List<String>? providerInstanceIds,
}) async { }) async {
try { try {
final response = await _sendCommand( final response = await _sendCommand(
@@ -1466,6 +1633,9 @@ class MusicAssistantAPI {
if (offset != null) 'offset': offset, if (offset != null) 'offset': offset,
if (search != null) 'search': search, if (search != null) 'search': search,
if (favoriteOnly != null) 'favorite': favoriteOnly, if (favoriteOnly != null) 'favorite': favoriteOnly,
if (orderBy != null) 'order_by': orderBy,
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty)
'provider': providerInstanceIds,
}, },
); );
@@ -1487,7 +1657,7 @@ class MusicAssistantAPI {
final response = await _sendCommand( final response = await _sendCommand(
'music/playlist', 'music/playlist',
args: { args: {
'provider': provider, 'provider_instance_id_or_domain': provider,
'item_id': itemId, 'item_id': itemId,
}, },
); );
@@ -2518,6 +2688,44 @@ class MusicAssistantAPI {
} }
} }
/// 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 // BUILT-IN PLAYER MANAGEMENT
// ============================================================================ // ============================================================================
@@ -3220,22 +3428,26 @@ class MusicAssistantAPI {
try { try {
_logger.log('📥 Fetching initial state...'); _logger.log('📥 Fetching initial state...');
// Fetch provider manifests (available providers) // Fetch provider manifests (available providers with icons)
try { try {
await _sendCommand('providers/manifests'); final manifestResponse = await _sendCommand('providers/manifests');
_logger.log('✓ Provider manifests loaded'); final manifests = manifestResponse['result'];
if (manifests is List) {
_providerManifests.clear();
for (final manifestData in manifests) {
if (manifestData is Map<String, dynamic>) {
final manifest = ProviderManifest.fromJson(manifestData);
if (manifest.domain.isNotEmpty) {
_providerManifests[manifest.domain] = manifest;
}
}
}
_logger.log('✓ Provider manifests loaded: ${_providerManifests.length} providers with ${_providerManifests.values.where((m) => m.hasSvgIcon).length} icons');
}
} catch (e) { } catch (e) {
_logger.log('⚠️ Could not load provider manifests: $e'); _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'); _logger.log('✅ Initial state fetch complete');
} catch (e) { } catch (e) {
_logger.log('❌ Error fetching state: $e'); _logger.log('❌ Error fetching state: $e');
@@ -3243,6 +3455,51 @@ 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 provider manifest by domain
ProviderManifest? getProviderManifest(String domain) {
return _providerManifests[domain];
}
/// Get SVG icon for a provider domain
/// Returns null if no icon is available
String? getProviderIconSvg(String domain, {bool isDark = false}) {
final manifest = _providerManifests[domain];
return manifest?.getSvgIcon(isDark: isDark);
}
/// Get current authenticated user's profile /// Get current authenticated user's profile
/// Returns user info including display_name, username, groups, etc. /// Returns user info including display_name, username, groups, etc.
Future<Map<String, dynamic>?> getCurrentUserInfo() async { Future<Map<String, dynamic>?> getCurrentUserInfo() async {
@@ -3378,6 +3635,9 @@ class MusicAssistantAPI {
return; return;
} }
// Cancel all pending requests before reconnecting to prevent memory leaks
_cancelPendingRequests('Connection lost, reconnecting');
await Future.delayed(Timings.reconnectDelay); await Future.delayed(Timings.reconnectDelay);
// Check again after delay // Check again after delay
+36 -4
View File
@@ -100,6 +100,7 @@ class PositionTracker {
Duration? duration, Duration? duration,
double? serverTimestamp, double? serverTimestamp,
}) { }) {
_logger.log('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
final bool playerChanged = _playerId != playerId; final bool playerChanged = _playerId != playerId;
final bool playStateChanged = _isPlaying != isPlaying; final bool playStateChanged = _isPlaying != isPlaying;
final bool durationChanged = duration != null && _duration != duration; final bool durationChanged = duration != null && _duration != duration;
@@ -155,17 +156,34 @@ class PositionTracker {
// 3. Position jumped significantly (seek or track change) - BUT NOT if it's a suspicious reset // 3. Position jumped significantly (seek or track change) - BUT NOT if it's a suspicious reset
// AND NOT if the timestamp is stale (stale data shouldn't override interpolated position) // AND NOT if the timestamp is stale (stale data shouldn't override interpolated position)
// 4. We're not playing (paused state should reflect server position) // 4. We're not playing (paused state should reflect server position)
// 5. Anchor is getting stale (> 20 seconds old) - prevents position freezing at _maxAnchorAge cap
// //
// Key insight: When timestamp is stale, the raw position from server is likely outdated. // Key insight: When timestamp is stale, the raw position from server is likely outdated.
// Our interpolated position is probably more accurate, so don't let stale data override it. // Our interpolated position is probably more accurate, so don't let stale data override it.
// HOWEVER, if our anchor itself is getting old, we need to refresh it to prevent freezing.
final anchorAge = DateTime.now().difference(_anchorTime);
final anchorIsGettingStale = anchorAge.inSeconds > 20;
// When anchor is stale, always refresh it - the suspicious reset check should
// only prevent us from trusting the SERVER's position, not from refreshing
// our own interpolated anchor. The newAnchorPos logic below handles using
// interpolated position for stale anchors with suspicious server data.
final shouldUpdateAnchor = playerChanged final shouldUpdateAnchor = playerChanged
|| playStateChanged || playStateChanged
|| (positionDiff > 2 && !isSuspiciousReset && !hasStaleTimestamp) || (positionDiff > 2 && !isSuspiciousReset && !hasStaleTimestamp)
|| !isPlaying; || !isPlaying
|| anchorIsGettingStale;
if (shouldUpdateAnchor) { if (shouldUpdateAnchor) {
_anchorPosition = anchorPos; // When refreshing due to stale anchor (not player/state change), use interpolated position
// to avoid backward jumps from outdated server data
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
: anchorPos; // Use server position for other cases
_logger.log('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
_anchorPosition = newAnchorPos;
_anchorTime = DateTime.now(); _anchorTime = DateTime.now();
} else if (positionDiff > 2) {
_logger.log('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
} }
_isPlaying = isPlaying; _isPlaying = isPlaying;
@@ -209,6 +227,7 @@ class PositionTracker {
/// Clear tracker state (e.g., when disconnecting) /// Clear tracker state (e.g., when disconnecting)
void clear() { void clear() {
_logger.log('⏱️ PositionTracker.clear() called');
_stopInterpolationTimer(); _stopInterpolationTimer();
_playerId = null; _playerId = null;
_isPlaying = false; _isPlaying = false;
@@ -218,9 +237,20 @@ class PositionTracker {
} }
void _startInterpolationTimer() { void _startInterpolationTimer() {
if (_interpolationTimer != null) return; if (_interpolationTimer != null) {
_logger.log('⏱️ PositionTracker: Timer already running, skipping start');
return;
}
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (_) { _logger.log('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
// Log every 4 ticks (1 second) if no emission happened
if (timer.tick % 4 == 0) {
final pos = currentPosition;
if (pos.inSeconds == _lastEmittedSeconds) {
_logger.log('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
}
}
_emitPosition(); _emitPosition();
}); });
@@ -229,6 +259,7 @@ class PositionTracker {
} }
void _stopInterpolationTimer() { void _stopInterpolationTimer() {
_logger.log('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
_interpolationTimer?.cancel(); _interpolationTimer?.cancel();
_interpolationTimer = null; _interpolationTimer = null;
} }
@@ -240,6 +271,7 @@ class PositionTracker {
// Only emit if second changed (reduces unnecessary updates) // Only emit if second changed (reduces unnecessary updates)
if (seconds != _lastEmittedSeconds) { if (seconds != _lastEmittedSeconds) {
_lastEmittedSeconds = seconds; _lastEmittedSeconds = seconds;
_logger.log('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
_positionController.add(pos); _positionController.add(pos);
} }
} }
+68 -5
View File
@@ -20,7 +20,17 @@ class SecureStorageService {
// Auth Token (for stream requests) // Auth Token (for stream requests)
static Future<String?> getAuthToken() async { 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 { static Future<void> setAuthToken(String? token) async {
@@ -33,7 +43,17 @@ class SecureStorageService {
// Music Assistant Native Auth Token (long-lived token) // Music Assistant Native Auth Token (long-lived token)
static Future<String?> getMaAuthToken() async { 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 { static Future<void> setMaAuthToken(String? token) async {
@@ -50,7 +70,18 @@ class SecureStorageService {
// Auth Credentials (serialized auth strategy credentials) // Auth Credentials (serialized auth strategy credentials)
static Future<Map<String, dynamic>?> getAuthCredentials() async { 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; if (json == null) return null;
try { try {
return jsonDecode(json) as Map<String, dynamic>; return jsonDecode(json) as Map<String, dynamic>;
@@ -69,7 +100,17 @@ class SecureStorageService {
// Password // Password
static Future<String?> getPassword() async { 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 { static Future<void> setPassword(String? password) async {
@@ -82,7 +123,17 @@ class SecureStorageService {
// Audiobookshelf API Token // Audiobookshelf API Token
static Future<String?> getAbsApiToken() async { 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 { static Future<void> setAbsApiToken(String? token) async {
@@ -98,12 +149,21 @@ class SecureStorageService {
await _storage.deleteAll(); await _storage.deleteAll();
} }
// Migration completed flag key
static const String _keyMigrationCompleted = 'secure_storage_migration_completed';
/// Migrate credentials from SharedPreferences to secure storage. /// Migrate credentials from SharedPreferences to secure storage.
/// Call this once during app upgrade to migrate existing users. /// 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 { static Future<void> migrateFromSharedPreferences() async {
// Import SharedPreferences for migration // Import SharedPreferences for migration
final SharedPreferences prefs = await SharedPreferences.getInstance(); final SharedPreferences prefs = await SharedPreferences.getInstance();
// Skip if migration already completed
if (prefs.getBool(_keyMigrationCompleted) == true) {
return;
}
// Migrate auth token // Migrate auth token
final authToken = prefs.getString('auth_token'); final authToken = prefs.getString('auth_token');
if (authToken != null && authToken.isNotEmpty) { if (authToken != null && authToken.isNotEmpty) {
@@ -144,5 +204,8 @@ class SecureStorageService {
await setAbsApiToken(absApiToken); await setAbsApiToken(absApiToken);
await prefs.remove('abs_api_token'); 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
+373 -102
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../database/database.dart' show BatchCacheItem;
import '../models/media_item.dart'; import '../models/media_item.dart';
import 'database_service.dart'; import 'database_service.dart';
import 'debug_logger.dart'; import 'debug_logger.dart';
@@ -17,6 +18,7 @@ enum SyncStatus {
/// Service for background library synchronization /// Service for background library synchronization
/// Loads from database cache first, then syncs from MA API in background /// 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 { class SyncService with ChangeNotifier {
static SyncService? _instance; static SyncService? _instance;
static final _logger = DebugLogger(); static final _logger = DebugLogger();
@@ -42,6 +44,13 @@ class SyncService with ChangeNotifier {
List<Audiobook> _cachedAudiobooks = []; List<Audiobook> _cachedAudiobooks = [];
List<Playlist> _cachedPlaylists = []; 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 // Getters
SyncStatus get status => _status; SyncStatus get status => _status;
String? get lastError => _lastError; String? get lastError => _lastError;
@@ -54,8 +63,15 @@ class SyncService with ChangeNotifier {
bool get hasCache => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty || bool get hasCache => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.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) /// Load library data from database cache (instant)
/// Call this on app startup for immediate data /// Call this on app startup for immediate data
/// Also loads source provider info for client-side filtering
Future<void> loadFromCache() async { Future<void> loadFromCache() async {
if (!_db.isInitialized) { if (!_db.isInitialized) {
_logger.log('⚠️ Database not initialized, skipping cache load'); _logger.log('⚠️ Database not initialized, skipping cache load');
@@ -65,52 +81,73 @@ class SyncService with ChangeNotifier {
try { try {
_logger.log('📦 Loading library from database cache...'); _logger.log('📦 Loading library from database cache...');
// Load albums from cache // Load albums from cache with source providers
final albumData = await _db.getCachedItems('album'); final albumDataWithProviders = await _db.getCachedItemsWithProviders('album');
_cachedAlbums = albumData.map((data) { _cachedAlbums = [];
_albumSourceProviders = {};
for (final (data, providers) in albumDataWithProviders) {
try { try {
return Album.fromJson(data); final album = Album.fromJson(data);
_cachedAlbums.add(album);
if (providers.isNotEmpty) {
_albumSourceProviders[album.itemId] = providers;
}
} catch (e) { } catch (e) {
_logger.log('⚠️ Failed to parse cached album: $e'); _logger.log('⚠️ Failed to parse cached album: $e');
return null;
} }
}).whereType<Album>().toList(); }
// Load artists from cache // Load artists from cache with source providers
final artistData = await _db.getCachedItems('artist'); final artistDataWithProviders = await _db.getCachedItemsWithProviders('artist');
_cachedArtists = artistData.map((data) { _cachedArtists = [];
_artistSourceProviders = {};
for (final (data, providers) in artistDataWithProviders) {
try { try {
return Artist.fromJson(data); final artist = Artist.fromJson(data);
_cachedArtists.add(artist);
if (providers.isNotEmpty) {
_artistSourceProviders[artist.itemId] = providers;
}
} catch (e) { } catch (e) {
_logger.log('⚠️ Failed to parse cached artist: $e'); _logger.log('⚠️ Failed to parse cached artist: $e');
return null;
} }
}).whereType<Artist>().toList(); }
// Load audiobooks from cache // Load audiobooks from cache with source providers
final audiobookData = await _db.getCachedItems('audiobook'); final audiobookDataWithProviders = await _db.getCachedItemsWithProviders('audiobook');
_cachedAudiobooks = audiobookData.map((data) { _cachedAudiobooks = [];
_audiobookSourceProviders = {};
for (final (data, providers) in audiobookDataWithProviders) {
try { try {
return Audiobook.fromJson(data); final audiobook = Audiobook.fromJson(data);
_cachedAudiobooks.add(audiobook);
if (providers.isNotEmpty) {
_audiobookSourceProviders[audiobook.itemId] = providers;
}
} catch (e) { } catch (e) {
_logger.log('⚠️ Failed to parse cached audiobook: $e'); _logger.log('⚠️ Failed to parse cached audiobook: $e');
return null;
} }
}).whereType<Audiobook>().toList(); }
// Load playlists from cache // Load playlists from cache with source providers
final playlistData = await _db.getCachedItems('playlist'); final playlistDataWithProviders = await _db.getCachedItemsWithProviders('playlist');
_cachedPlaylists = playlistData.map((data) { _cachedPlaylists = [];
_playlistSourceProviders = {};
for (final (data, providers) in playlistDataWithProviders) {
try { try {
return Playlist.fromJson(data); final playlist = Playlist.fromJson(data);
_cachedPlaylists.add(playlist);
if (providers.isNotEmpty) {
_playlistSourceProviders[playlist.itemId] = providers;
}
} catch (e) { } catch (e) {
_logger.log('⚠️ Failed to parse cached playlist: $e'); _logger.log('⚠️ Failed to parse cached playlist: $e');
return null;
} }
}).whereType<Playlist>().toList(); }
_logger.log('📦 Loaded ${_cachedAlbums.length} albums, ${_cachedArtists.length} artists, ' _logger.log('📦 Loaded ${_cachedAlbums.length} albums, ${_cachedArtists.length} artists, '
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists from cache'); '${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists from cache');
_logger.log('📦 Source providers: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists tracked');
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
_logger.log('❌ Failed to load from cache: $e'); _logger.log('❌ Failed to load from cache: $e');
@@ -119,7 +156,12 @@ class SyncService with ChangeNotifier {
/// Sync library data from MA API in background /// Sync library data from MA API in background
/// Updates database cache and notifies listeners when complete /// 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) { if (_isSyncing) {
_logger.log('🔄 Sync already in progress, skipping'); _logger.log('🔄 Sync already in progress, skipping');
return; return;
@@ -155,33 +197,140 @@ class SyncService with ChangeNotifier {
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums(); final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums'); _logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums');
// Fetch fresh data from MA API (in parallel for speed) // Only clear cache for full syncs (no specific providers)
final results = await Future.wait([ // Partial syncs preserve items from disabled providers for instant toggle-on
api.getAlbums(limit: 1000), final isPartialSync = providerInstanceIds != null && providerInstanceIds.isNotEmpty;
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums), if (!isPartialSync) {
api.getAudiobooks(limit: 1000), await _db.clearCacheForType('album');
api.getPlaylists(limit: 1000), await _db.clearCacheForType('artist');
]); await _db.clearCacheForType('audiobook');
await _db.clearCacheForType('playlist');
}
final albums = results[0] as List<Album>; // Build NEW source tracking maps - don't modify existing until sync is complete
final artists = results[1] as List<Artist>; // This prevents UI flicker from partial tracking during sync
final audiobooks = results[2] as List<Audiobook>; final syncingProviders = providerInstanceIds?.toSet() ?? <String>{};
final playlists = results[3] as List<Playlist>; 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, ' // For partial syncs, copy tracking from non-syncing providers
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA'); 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) // Collect all items (deduped by itemId, but tracking all source providers)
await _db.clearCacheForType('album'); final albumMap = <String, Album>{};
await _db.clearCacheForType('artist'); final artistMap = <String, Artist>{};
await _db.clearCacheForType('audiobook'); final audiobookMap = <String, Audiobook>{};
await _db.clearCacheForType('playlist'); final playlistMap = <String, Playlist>{};
// Save to database cache // If specific providers are requested, sync each separately for accurate source tracking
await _saveAlbumsToCache(albums); if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
await _saveArtistsToCache(artists); _logger.log('🔒 Per-provider sync for ${providerInstanceIds.length} providers');
await _saveAudiobooksToCache(audiobooks);
await _savePlaylistsToCache(playlists); 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 // Update sync metadata
await _db.updateSyncMetadata('albums', albums.length); await _db.updateSyncMetadata('albums', albums.length);
@@ -190,14 +339,49 @@ class SyncService with ChangeNotifier {
await _db.updateSyncMetadata('playlists', playlists.length); await _db.updateSyncMetadata('playlists', playlists.length);
// Update in-memory cache // Update in-memory cache
_cachedAlbums = albums; if (isPartialSync) {
_cachedArtists = artists; // Partial sync: merge with existing cache to preserve items from disabled providers
_cachedAudiobooks = audiobooks; final albumIds = albums.map((a) => a.itemId).toSet();
_cachedPlaylists = playlists; 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(); _lastSyncTime = DateTime.now();
_status = SyncStatus.completed; _status = SyncStatus.completed;
_logger.log('✅ Library sync complete'); _logger.log('✅ Library sync complete');
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
} catch (e) { } catch (e) {
_logger.log('❌ Library sync failed: $e'); _logger.log('❌ Library sync failed: $e');
_status = SyncStatus.error; _status = SyncStatus.error;
@@ -208,73 +392,90 @@ class SyncService with ChangeNotifier {
} }
} }
/// Save albums to database cache /// Save albums to database cache with source provider tracking (batched)
Future<void> _saveAlbumsToCache(List<Album> albums) async { Future<void> _saveAlbumsToCache(List<Album> albums, Map<String, List<String>> sourceProviders) async {
final batchItems = <BatchCacheItem>[];
for (final album in albums) { for (final album in albums) {
try { final providers = sourceProviders[album.itemId] ?? <String>[];
await _db.cacheItem( batchItems.add(BatchCacheItem(
itemType: 'album', itemType: 'album',
itemId: album.itemId, itemId: album.itemId,
data: album.toJson(), data: jsonEncode(album.toJson()),
); sourceProviders: providers,
} catch (e) { ));
_logger.log('⚠️ Failed to cache album ${album.name}: $e'); }
} try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${albums.length} albums to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache albums: $e');
} }
_logger.log('💾 Saved ${albums.length} albums to cache');
} }
/// Save artists to database cache /// Save artists to database cache with source provider tracking (batched)
Future<void> _saveArtistsToCache(List<Artist> artists) async { Future<void> _saveArtistsToCache(List<Artist> artists, Map<String, List<String>> sourceProviders) async {
final batchItems = <BatchCacheItem>[];
for (final artist in artists) { for (final artist in artists) {
try { final providers = sourceProviders[artist.itemId] ?? <String>[];
await _db.cacheItem( batchItems.add(BatchCacheItem(
itemType: 'artist', itemType: 'artist',
itemId: artist.itemId, itemId: artist.itemId,
data: artist.toJson(), data: jsonEncode(artist.toJson()),
); sourceProviders: providers,
} catch (e) { ));
_logger.log('⚠️ Failed to cache artist ${artist.name}: $e'); }
} try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${artists.length} artists to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache artists: $e');
} }
_logger.log('💾 Saved ${artists.length} artists to cache');
} }
/// Save audiobooks to database cache /// Save audiobooks to database cache with source provider tracking (batched)
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks) async { Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks, Map<String, List<String>> sourceProviders) async {
final batchItems = <BatchCacheItem>[];
for (final audiobook in audiobooks) { for (final audiobook in audiobooks) {
try { final providers = sourceProviders[audiobook.itemId] ?? <String>[];
await _db.cacheItem( batchItems.add(BatchCacheItem(
itemType: 'audiobook', itemType: 'audiobook',
itemId: audiobook.itemId, itemId: audiobook.itemId,
data: audiobook.toJson(), data: jsonEncode(audiobook.toJson()),
); sourceProviders: providers,
} catch (e) { ));
_logger.log('⚠️ Failed to cache audiobook ${audiobook.name}: $e'); }
} try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${audiobooks.length} audiobooks to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache audiobooks: $e');
} }
_logger.log('💾 Saved ${audiobooks.length} audiobooks to cache');
} }
/// Save playlists to database cache /// Save playlists to database cache with source provider tracking (batched)
Future<void> _savePlaylistsToCache(List<Playlist> playlists) async { Future<void> _savePlaylistsToCache(List<Playlist> playlists, Map<String, List<String>> sourceProviders) async {
final batchItems = <BatchCacheItem>[];
for (final playlist in playlists) { for (final playlist in playlists) {
try { final providers = sourceProviders[playlist.itemId] ?? <String>[];
await _db.cacheItem( batchItems.add(BatchCacheItem(
itemType: 'playlist', itemType: 'playlist',
itemId: playlist.itemId, itemId: playlist.itemId,
data: playlist.toJson(), data: jsonEncode(playlist.toJson()),
); sourceProviders: providers,
} catch (e) { ));
_logger.log('⚠️ Failed to cache playlist ${playlist.name}: $e'); }
} try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${playlists.length} playlists to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache playlists: $e');
} }
_logger.log('💾 Saved ${playlists.length} playlists to cache');
} }
/// Force a fresh sync (for pull-to-refresh) /// Force a fresh sync (for pull-to-refresh)
Future<void> forceSync(MusicAssistantAPI api) async { /// [providerInstanceIds] - optional list of provider IDs to filter by (null = all providers)
await syncFromApi(api, force: true); Future<void> forceSync(MusicAssistantAPI api, {List<String>? providerInstanceIds}) async {
await syncFromApi(api, force: true, providerInstanceIds: providerInstanceIds);
} }
/// Clear all cached data /// Clear all cached data
@@ -286,12 +487,82 @@ class SyncService with ChangeNotifier {
_cachedArtists = []; _cachedArtists = [];
_cachedAudiobooks = []; _cachedAudiobooks = [];
_cachedPlaylists = []; _cachedPlaylists = [];
_albumSourceProviders = {};
_artistSourceProviders = {};
_audiobookSourceProviders = {};
_playlistSourceProviders = {};
_lastSyncTime = null; _lastSyncTime = null;
_status = SyncStatus.idle; _status = SyncStatus.idle;
notifyListeners(); notifyListeners();
_logger.log('🗑️ Library cache cleared'); _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) /// Get albums (from cache or empty if not loaded)
List<Album> getAlbums() => _cachedAlbums; List<Album> getAlbums() => _cachedAlbums;
+66
View File
@@ -124,3 +124,69 @@ class Durations {
static const Duration normal = Duration(milliseconds: 300); static const Duration normal = Duration(milliseconds: 300);
static const Duration slow = Duration(milliseconds: 500); static const Duration slow = Duration(milliseconds: 500);
} }
/// Button size tokens
class ButtonSizes {
ButtonSizes._();
// Touch target sizes
// WCAG 2.1 Level AA requires minimum 44x44, Level AAA requires 48x48
// Material Design recommends 48x48 minimum
// WARNING: sm and md are below WCAG minimum - use only for non-interactive elements
/// 32x32 - NOT WCAG compliant, use only for decorative/non-interactive elements
@Deprecated('Use xl (48x48) for touch targets to meet WCAG accessibility guidelines')
static const double sm = 32.0;
/// 36x36 - NOT WCAG compliant, use only for dense layouts where space is critical
@Deprecated('Use xl (48x48) for touch targets to meet WCAG accessibility guidelines')
static const double md = 36.0;
/// 44x44 - Meets WCAG Level AA minimum
static const double lg = 44.0;
/// 48x48 - Meets WCAG Level AAA and Material Design recommended minimum
static const double xl = 48.0;
/// 56x56 - Comfortable touch target for primary actions
static const double xxl = 56.0;
/// Minimum WCAG-compliant touch target size (48x48)
static const double minAccessible = 48.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);
}
+46
View File
@@ -0,0 +1,46 @@
/// Extension methods for formatting Duration values.
///
/// Provides consistent duration formatting across the codebase,
/// replacing duplicate _formatDuration() implementations.
extension DurationFormatter on Duration {
/// Format duration as "H:MM:SS" or "M:SS" depending on length.
///
/// Examples:
/// - 45 seconds -> "0:45"
/// - 3 minutes, 45 seconds -> "3:45"
/// - 1 hour, 3 minutes, 45 seconds -> "1:03:45"
String toFormattedString() {
final hours = inHours;
final minutes = inMinutes % 60;
final seconds = inSeconds % 60;
if (hours > 0) {
return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
/// Format duration as "M:SS" (no hours, minutes not padded).
///
/// Use this for shorter content like songs where hours are unlikely.
String toMinutesSeconds() {
final totalSeconds = inSeconds;
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}
/// Format an integer number of seconds as "H:MM:SS" or "M:SS".
///
/// This is a standalone function for cases where you have raw seconds
/// rather than a Duration object.
String formatDurationSeconds(int seconds) {
return Duration(seconds: seconds).toFormattedString();
}
/// Format a nullable Duration, returning empty string if null.
String formatDurationOrEmpty(Duration? duration) {
if (duration == null) return '';
return duration.toMinutesSeconds();
}
+139
View File
@@ -0,0 +1,139 @@
import 'dart:collection';
/// A simple Least Recently Used (LRU) cache implementation.
///
/// When the cache reaches its maximum size, the least recently used entries
/// are automatically evicted to make room for new entries.
///
/// Usage:
/// ```dart
/// final cache = LruCache<String, String>(maxSize: 100);
/// cache.put('key', 'value');
/// final value = cache.get('key');
/// ```
class LruCache<K, V> {
/// Maximum number of entries to store
final int maxSize;
/// Internal storage using LinkedHashMap for O(1) LRU operations
final LinkedHashMap<K, V> _cache = LinkedHashMap<K, V>();
/// Creates an LRU cache with the specified maximum size.
///
/// [maxSize] must be greater than 0.
LruCache({required this.maxSize}) : assert(maxSize > 0);
/// Returns the number of entries currently in the cache.
int get length => _cache.length;
/// Returns true if the cache is empty.
bool get isEmpty => _cache.isEmpty;
/// Returns true if the cache is not empty.
bool get isNotEmpty => _cache.isNotEmpty;
/// Returns true if the cache contains an entry for [key].
bool containsKey(K key) => _cache.containsKey(key);
/// Returns the value for [key], or null if not found.
///
/// This operation marks the entry as recently used.
V? get(K key) {
if (!_cache.containsKey(key)) {
return null;
}
// Move to end (most recently used) by removing and re-adding
final value = _cache.remove(key);
_cache[key] = value as V;
return value;
}
/// Returns the value for [key] without marking it as recently used.
///
/// Use this when you need to check a value without affecting LRU order.
V? peek(K key) => _cache[key];
/// Stores [value] for [key] in the cache.
///
/// If [key] already exists, the value is updated and marked as recently used.
/// If the cache is full, the least recently used entry is evicted.
void put(K key, V value) {
// If key exists, remove it first (will be re-added at end)
if (_cache.containsKey(key)) {
_cache.remove(key);
} else if (_cache.length >= maxSize) {
// Cache is full, remove least recently used (first entry)
_cache.remove(_cache.keys.first);
}
_cache[key] = value;
}
/// Operator access for getting values (same as [get]).
V? operator [](K key) => get(key);
/// Operator access for setting values (same as [put]).
void operator []=(K key, V value) => put(key, value);
/// Removes the entry for [key] if it exists.
///
/// Returns the removed value, or null if [key] was not in the cache.
V? remove(K key) => _cache.remove(key);
/// Removes all entries from the cache.
void clear() => _cache.clear();
/// Returns all keys currently in the cache.
Iterable<K> get keys => _cache.keys;
/// Returns all values currently in the cache.
Iterable<V> get values => _cache.values;
/// Returns all entries currently in the cache.
Iterable<MapEntry<K, V>> get entries => _cache.entries;
}
/// An LRU cache that can store null values and distinguish them from missing entries.
///
/// Regular LruCache returns null for both missing keys and keys with null values.
/// This class uses a wrapper to distinguish between the two cases.
class NullableLruCache<K, V> {
final LruCache<K, _CacheEntry<V>> _cache;
/// Creates a nullable LRU cache with the specified maximum size.
NullableLruCache({required int maxSize}) : _cache = LruCache(maxSize: maxSize);
/// Returns the number of entries currently in the cache.
int get length => _cache.length;
/// Returns true if the cache contains an entry for [key].
bool containsKey(K key) => _cache.containsKey(key);
/// Returns the value for [key], or null if not found.
///
/// Note: This will also return null if the cached value is null.
/// Use [containsKey] to distinguish between missing and null values.
V? get(K key) => _cache.get(key)?.value;
/// Stores [value] for [key] in the cache.
///
/// The value can be null.
void put(K key, V? value) => _cache.put(key, _CacheEntry(value));
/// Operator access for getting values.
V? operator [](K key) => get(key);
/// Operator access for setting values.
void operator []=(K key, V? value) => put(key, value);
/// Removes the entry for [key] if it exists.
V? remove(K key) => _cache.remove(key)?.value;
/// Removes all entries from the cache.
void clear() => _cache.clear();
}
/// Wrapper to store nullable values in the cache
class _CacheEntry<V> {
final V? value;
_CacheEntry(this.value);
}
+73 -61
View File
@@ -6,9 +6,11 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../screens/album_details_screen.dart'; import '../screens/album_details_screen.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import 'provider_icon.dart';
class AlbumCard extends StatefulWidget { class AlbumCard extends StatefulWidget {
final Album album; final Album album;
@@ -36,9 +38,11 @@ class _AlbumCardState extends State<AlbumCard> {
bool _maImageFailed = false; bool _maImageFailed = false;
String? _cachedMaImageUrl; String? _cachedMaImageUrl;
Timer? _fallbackTimer; Timer? _fallbackTimer;
bool _isNavigating = false;
/// Delay before fetching fallback images to avoid requests during fast scroll /// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200); /// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
static const _fallbackDelay = Duration(milliseconds: 400);
@override @override
void initState() { void initState() {
@@ -117,8 +121,12 @@ class _AlbumCardState extends State<AlbumCard> {
return RepaintBoundary( return RepaintBoundary(
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap ?? () { onTap: widget.onTap ?? () {
// Update adaptive colors immediately on tap // Prevent double-tap navigation
updateAdaptiveColorsFromImage(context, imageUrl); if (_isNavigating) return;
_isNavigating = true;
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push( Navigator.push(
context, context,
FadeSlidePageRoute( FadeSlidePageRoute(
@@ -128,7 +136,12 @@ class _AlbumCardState extends State<AlbumCard> {
initialImageUrl: imageUrl, initialImageUrl: imageUrl,
), ),
), ),
); ).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
}, },
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -140,72 +153,71 @@ class _AlbumCardState extends State<AlbumCard> {
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + suffix, tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + suffix,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
child: Container( child: Stack(
color: colorScheme.surfaceVariant, fit: StackFit.expand,
child: imageUrl != null children: [
? CachedNetworkImage( Container(
imageUrl: imageUrl, color: colorScheme.surfaceVariant,
fit: BoxFit.cover, child: imageUrl != null
memCacheWidth: cacheSize, ? CachedNetworkImage(
memCacheHeight: cacheSize, imageUrl: imageUrl,
fadeInDuration: Duration.zero, fit: BoxFit.cover,
fadeOutDuration: Duration.zero, memCacheWidth: cacheSize,
placeholder: (context, url) => const SizedBox(), memCacheHeight: cacheSize,
errorWidget: (context, url, error) { fadeInDuration: Duration.zero,
// Try fallback on error (only for MA URLs, not fallback URLs) fadeOutDuration: Duration.zero,
if (!_maImageFailed && url == maImageUrl) { placeholder: (context, url) => const SizedBox(),
WidgetsBinding.instance.addPostFrameCallback((_) { errorWidget: (context, url, error) {
_onImageError(); // Try fallback on error (only for MA URLs, not fallback URLs)
}); if (!_maImageFailed && url == maImageUrl) {
} WidgetsBinding.instance.addPostFrameCallback((_) {
return Icon( _onImageError();
Icons.album_rounded, });
size: 64, }
color: colorScheme.onSurfaceVariant, return Icon(
); Icons.album_rounded,
}, size: 64,
) color: colorScheme.onSurfaceVariant,
: Center( );
child: Icon( },
Icons.album_rounded, )
size: 64, : Center(
color: colorScheme.onSurfaceVariant, child: Icon(
), Icons.album_rounded,
), size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.album.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.album.providerMappings!.first.providerDomain,
),
],
), ),
), ),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Album title with year // Album title with year
Hero( // PERF: Removed Hero - text animations provide minimal benefit but add overhead
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix, Text(
child: Material( widget.album.nameWithYear,
color: Colors.transparent, maxLines: 1,
child: Text( overflow: TextOverflow.ellipsis,
widget.album.nameWithYear, style: textTheme.titleSmall?.copyWith(
maxLines: 1, color: colorScheme.onSurface,
overflow: TextOverflow.ellipsis, fontWeight: FontWeight.w500,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
), ),
), ),
// Artist name // Artist name
Hero( Text(
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + suffix, widget.album.artistsString,
child: Material( maxLines: 1,
color: Colors.transparent, overflow: TextOverflow.ellipsis,
child: Text( style: textTheme.bodySmall?.copyWith(
widget.album.artistsString, color: colorScheme.onSurface.withOpacity(0.7),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
), ),
), ),
], ],
+26 -19
View File
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../screens/artist_details_screen.dart'; import '../screens/artist_details_screen.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
@@ -38,9 +39,11 @@ class _ArtistCardState extends State<ArtistCard> {
bool _maImageFailed = false; bool _maImageFailed = false;
String? _cachedMaImageUrl; String? _cachedMaImageUrl;
Timer? _fallbackTimer; Timer? _fallbackTimer;
bool _isNavigating = false;
/// Delay before fetching fallback images to avoid requests during fast scroll /// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200); /// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
static const _fallbackDelay = Duration(milliseconds: 400);
@override @override
void initState() { void initState() {
@@ -110,8 +113,12 @@ class _ArtistCardState extends State<ArtistCard> {
return RepaintBoundary( return RepaintBoundary(
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap ?? () { onTap: widget.onTap ?? () {
// Update adaptive colors immediately on tap // Prevent double-tap navigation
updateAdaptiveColorsFromImage(context, imageUrl); if (_isNavigating) return;
_isNavigating = true;
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push( Navigator.push(
context, context,
FadeSlidePageRoute( FadeSlidePageRoute(
@@ -121,7 +128,12 @@ class _ArtistCardState extends State<ArtistCard> {
initialImageUrl: imageUrl, initialImageUrl: imageUrl,
), ),
), ),
); ).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
}, },
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -175,23 +187,18 @@ class _ArtistCardState extends State<ArtistCard> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Artist name - fixed height container so image size is consistent // Artist name - fixed height container so image size is consistent
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
SizedBox( SizedBox(
height: 36, // Fixed height for 2 lines of text height: 36, // Fixed height for 2 lines of text
child: Hero( child: Text(
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix, widget.artist.name,
child: Material( maxLines: 2,
color: Colors.transparent, overflow: TextOverflow.ellipsis,
child: Text( textAlign: TextAlign.center,
widget.artist.name, style: textTheme.titleSmall?.copyWith(
maxLines: 2, color: colorScheme.onSurface,
overflow: TextOverflow.ellipsis, fontWeight: FontWeight.w500,
textAlign: TextAlign.center, height: 1.15,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.15,
),
),
), ),
), ),
), ),
+6
View File
@@ -8,6 +8,7 @@ import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../screens/audiobook_detail_screen.dart'; import '../screens/audiobook_detail_screen.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import 'provider_icon.dart';
class AudiobookRow extends StatefulWidget { class AudiobookRow extends StatefulWidget {
final String title; final String title;
@@ -285,6 +286,11 @@ class _AudiobookCard extends StatelessWidget {
), ),
), ),
), ),
// Provider icon overlay
if (audiobook.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: audiobook.providerMappings!.first.providerDomain,
),
], ],
), ),
), ),
+93 -75
View File
@@ -103,39 +103,48 @@ class EmptyState extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Center( // Wrap in scrollable to allow PageView horizontal swipe detection
child: Padding( return LayoutBuilder(
padding: Spacing.paddingAll24, builder: (context, constraints) => SingleChildScrollView(
child: Column( physics: const AlwaysScrollableScrollPhysics(),
mainAxisAlignment: MainAxisAlignment.center, child: ConstrainedBox(
children: [ constraints: BoxConstraints(minHeight: constraints.maxHeight),
Icon( child: Center(
icon, child: Padding(
size: iconSize, padding: Spacing.paddingAll24,
color: colorScheme.onSurface.withOpacity(0.38), child: Column(
), mainAxisAlignment: MainAxisAlignment.center,
Spacing.vGap16, children: [
Text( Icon(
message, icon,
style: TextStyle( size: iconSize,
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.38),
fontSize: 16, ),
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 colorScheme = Theme.of(context).colorScheme;
final s = S.of(context)!; final s = S.of(context)!;
return Center( // Wrap in scrollable to allow PageView horizontal swipe detection
child: Padding( return LayoutBuilder(
padding: Spacing.paddingAll24, builder: (context, constraints) => SingleChildScrollView(
child: Column( physics: const AlwaysScrollableScrollPhysics(),
mainAxisAlignment: MainAxisAlignment.center, child: ConstrainedBox(
children: [ constraints: BoxConstraints(minHeight: constraints.maxHeight),
Icon( child: Center(
icon, child: Padding(
size: iconSize, padding: Spacing.paddingAll24,
color: colorScheme.onSurface.withOpacity(0.38), 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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../l10n/app_localizations.dart';
import '../../services/debug_logger.dart'; import '../../services/debug_logger.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
@@ -72,9 +73,9 @@ class DebugConsole extends StatelessWidget {
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
minimumSize: const Size(40, 20), minimumSize: const Size(40, 20),
), ),
child: const Text( child: Text(
'Clear', S.of(context)!.clear,
style: TextStyle(fontSize: 10, color: Colors.green), style: const TextStyle(fontSize: 10, color: Colors.green),
), ),
), ),
], ],
@@ -86,17 +87,17 @@ class DebugConsole extends StatelessWidget {
if (logs.isNotEmpty) { if (logs.isNotEmpty) {
Clipboard.setData(ClipboardData(text: logs)); Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Logs copied to clipboard'), content: Text(S.of(context)!.logsCopied),
backgroundColor: Colors.green, backgroundColor: Colors.green,
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('No logs to copy'), content: Text(S.of(context)!.noLogsToCopy),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
} }
File diff suppressed because it is too large Load Diff
+145 -63
View File
@@ -93,8 +93,15 @@ class GlobalPlayerOverlay extends StatefulWidget {
State<GlobalPlayerOverlay> createState() => _GlobalPlayerOverlayState(); State<GlobalPlayerOverlay> createState() => _GlobalPlayerOverlayState();
/// Collapse the player if it's expanded /// Collapse the player if it's expanded
static void collapsePlayer() { /// [withHaptic]: Set to false for Android back gesture (system provides haptic)
globalPlayerKey.currentState?.collapse(); static void collapsePlayer({bool withHaptic = true}) {
globalPlayerKey.currentState?.collapse(withHaptic: withHaptic);
}
/// 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 /// Check if the player is currently expanded
@@ -158,6 +165,21 @@ class GlobalPlayerOverlay extends StatefulWidget {
/// Check if the player reveal is currently visible /// Check if the player reveal is currently visible
static bool get isPlayerRevealVisible => static bool get isPlayerRevealVisible =>
_overlayStateKey.currentState?._isRevealVisible ?? false; _overlayStateKey.currentState?._isRevealVisible ?? false;
/// Show player selector for a "Play On" action.
/// Instead of the default hints, shows a contextual hint like "Select player to play album".
/// When a player is tapped, calls onPlayerSelected instead of switching to that player.
static void showPlayerSelectorForAction({
required String contextHint,
required void Function(dynamic player) onPlayerSelected,
IconData? hintIcon,
}) {
_overlayStateKey.currentState?._showPlayerSelectorForAction(
contextHint: contextHint,
onPlayerSelected: onPlayerSelected,
hintIcon: hintIcon,
);
}
} }
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay> class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
@@ -202,6 +224,11 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
// Key for the reveal overlay // Key for the reveal overlay
final _revealKey = GlobalKey<PlayerRevealOverlayState>(); final _revealKey = GlobalKey<PlayerRevealOverlayState>();
// State for "Play On" action - stored here so it can be passed to PlayerRevealOverlay
void Function(dynamic player)? _pendingPlayerAction;
String? _pendingContextHint;
IconData? _pendingHintIcon;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -388,6 +415,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
void _hidePlayerReveal() { void _hidePlayerReveal() {
if (!_isRevealVisible) return; if (!_isRevealVisible) return;
_bounceOffsetNotifier.value = 0; _bounceOffsetNotifier.value = 0;
_clearPendingAction(); // Clear pending action when overlay is dismissed
setState(() { setState(() {
_isRevealVisible = false; _isRevealVisible = false;
_isOnboardingReveal = false; _isOnboardingReveal = false;
@@ -407,6 +435,29 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
_singleBounceController.forward(); _singleBounceController.forward();
} }
/// Clear pending action state (called when overlay is dismissed)
void _clearPendingAction() {
_pendingPlayerAction = null;
_pendingContextHint = null;
_pendingHintIcon = null;
}
/// Show player selector for a "Play On" action.
/// Stores the pending action and shows the reveal overlay with context hint.
void _showPlayerSelectorForAction({
required String contextHint,
required void Function(dynamic player) onPlayerSelected,
IconData? hintIcon,
}) {
// Store pending action state
_pendingPlayerAction = onPlayerSelected;
_pendingContextHint = contextHint;
_pendingHintIcon = hintIcon;
// Show the reveal overlay
_showPlayerReveal();
}
/// End hint mode (called when user taps skip button) /// End hint mode (called when user taps skip button)
void _endHintMode() { void _endHintMode() {
if (!_isHintModeActive) return; if (!_isHintModeActive) return;
@@ -426,15 +477,17 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
// Watch provider to trigger rebuilds when connection state changes // PERF: Use select() to only rebuild when connection state or player changes
final provider = context.watch<MusicAssistantProvider>(); 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. // 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. // This is SYNCHRONOUS - no setState needed - so it works in the same frame.
final isFirstTimeUserReady = _settingsLoaded && final isFirstTimeUserReady = _settingsLoaded &&
!_hasCompletedOnboarding && !_hasCompletedOnboarding &&
provider.isConnected && isConnected &&
provider.selectedPlayer != null; hasSelectedPlayer;
// Show welcome backdrop if: // Show welcome backdrop if:
// 1. Already in hint mode (_isHintModeActive), OR // 1. Already in hint mode (_isHintModeActive), OR
@@ -480,7 +533,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
// Hide when: not connected, no player selected, OR showing welcome screen // Hide when: not connected, no player selected, OR showing welcome screen
// For first-time users, selectedPlayer becomes non-null at the same time // For first-time users, selectedPlayer becomes non-null at the same time
// as shouldShowWelcomeBackdrop becomes true, preventing any gap. // as shouldShowWelcomeBackdrop becomes true, preventing any gap.
if (provider.isConnected && provider.selectedPlayer != null && !shouldShowWelcomeBackdrop) if (isConnected && hasSelectedPlayer && !shouldShowWelcomeBackdrop)
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
@@ -528,62 +581,76 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
} }
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark); final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
return Container( // Fade out and slide down nav bar as player expands
decoration: BoxDecoration( // Use IgnorePointer when faded to prevent accidental taps
color: navBgColor, final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
boxShadow: expansionState.progress < 0.5 final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
? [
BoxShadow( return IgnorePointer(
color: Colors.black.withOpacity(0.1), ignoring: navOpacity < 0.1,
blurRadius: 10, child: Transform.translate(
offset: const Offset(0, -2), offset: Offset(0, navSlideDown),
child: Opacity(
opacity: navOpacity,
child: Container(
decoration: BoxDecoration(
color: navBgColor,
boxShadow: expansionState.progress < 0.5
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
]
: null,
),
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(
: null, icon: const Icon(Icons.library_music_outlined),
), activeIcon: const Icon(Icons.library_music_rounded),
child: BottomNavigationBar( label: S.of(context)!.library,
currentIndex: navigationProvider.selectedIndex, ),
onTap: (index) { BottomNavigationBarItem(
if (GlobalPlayerOverlay.isPlayerExpanded) { icon: const Icon(Icons.search_rounded),
GlobalPlayerOverlay.collapsePlayer(); activeIcon: const Icon(Icons.search_rounded),
} label: S.of(context)!.search,
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst); ),
if (index == 3) { BottomNavigationBarItem(
GlobalPlayerOverlay.hidePlayer(); icon: const Icon(Icons.settings_outlined),
} else if (navigationProvider.selectedIndex == 3) { activeIcon: const Icon(Icons.settings_rounded),
GlobalPlayerOverlay.showPlayer(); label: S.of(context)!.settings,
} ),
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,
),
],
), ),
); );
}, },
@@ -619,11 +686,15 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
curve: const Interval(0.67, 1.0, curve: Curves.easeOut), curve: const Interval(0.67, 1.0, curve: Curves.easeOut),
builder: (context, opacity, child) { builder: (context, opacity, child) {
// Use theme-appropriate background color for seamless transition
final isDark = Theme.of(context).brightness == Brightness.dark;
final backdropColor = isDark
? const Color(0xFF1a1a1a) // Dark theme background
: colorScheme.surface; // Light theme surface
return BackdropFilter( return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0), filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container( child: Container(
// Use same dark color as connection screen for seamless transition color: backdropColor.withOpacity(opacity),
color: const Color(0xFF1a1a1a).withOpacity(opacity),
), ),
); );
}, },
@@ -719,6 +790,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12, miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
miniPlayerHeight: 64, miniPlayerHeight: 64,
showOnboardingHints: _isOnboardingReveal, showOnboardingHints: _isOnboardingReveal,
// Pass pending action parameters for "Play On" functionality
onPlayerSelected: _pendingPlayerAction,
contextHint: _pendingContextHint,
contextHintIcon: _pendingHintIcon,
), ),
// Global player overlay - renders ON TOP so cards slide behind it // Global player overlay - renders ON TOP so cards slide behind it
@@ -732,6 +807,13 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
builder: (context, state, child) { builder: (context, state, child) {
// Only show player if connected and has a selected player // Only show player if connected and has a selected player
if (!state.isConnected || !state.hasPlayer) { if (!state.isConnected || !state.hasPlayer) {
// Reset expansion state to prevent stale values from causing
// invisible/non-interactive nav bar when connection restores.
// This fixes a bug where expanded player state persisted after
// disconnect, making nav bar opacity=0 and ignoring taps.
if (playerExpansionNotifier.value.progress != 0.0) {
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
}
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
+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 /// A scrollbar that shows a letter popup when dragging, for fast navigation
/// through alphabetically sorted lists. /// 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 { class LetterScrollbar extends StatefulWidget {
/// The scrollable child widget (ListView, GridView, etc.) /// The scrollable child widget (ListView, GridView, etc.)
final Widget child; final Widget child;
@@ -9,16 +12,34 @@ class LetterScrollbar extends StatefulWidget {
/// The scroll controller for the child /// The scroll controller for the child
final ScrollController controller; final ScrollController controller;
/// List of items to extract letters from (must be sorted alphabetically) /// List of strings to extract letters from (should match visual sort order).
/// Each item's first character is used to determine the letter /// Each item's first character is used to determine the letter shown in the popup.
final List<String> items; 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; final void Function(int index)? onScrollToIndex;
/// Callback when drag state changes (for disabling scroll-to-hide) /// Callback when drag state changes (for disabling scroll-to-hide)
final void Function(bool isDragging)? onDragStateChanged; 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({ const LetterScrollbar({
super.key, super.key,
required this.child, required this.child,
@@ -26,6 +47,12 @@ class LetterScrollbar extends StatefulWidget {
required this.items, required this.items,
this.onScrollToIndex, this.onScrollToIndex,
this.onDragStateChanged, this.onDragStateChanged,
this.itemExtent,
this.crossAxisCount,
this.childAspectRatio,
this.mainAxisSpacing,
this.horizontalPadding,
this.bottomPadding = 0,
}); });
@override @override
@@ -55,87 +82,54 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
final position = widget.controller.position; final position = widget.controller.position;
if (position.maxScrollExtent > 0) { if (position.maxScrollExtent > 0) {
final newFraction = (position.pixels / position.maxScrollExtent).clamp(0.0, 1.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) { if ((newFraction - _scrollFraction).abs() > 0.01) {
setState(() { setState(() {
_scrollFraction = newFraction; _scrollFraction = newFraction;
_currentLetter = _getLetterAtFraction(newFraction);
}); });
} }
} }
} }
} }
// Build a map of letter -> first index for that letter /// Normalizes a character to a letter for the scrollbar.
Map<String, int> get _letterIndexMap { String _normalizeToLetter(String char) {
final map = <String, int>{}; if (char.isEmpty) return '';
for (int i = 0; i < widget.items.length; i++) { final upper = char[0].toUpperCase();
final item = widget.items[i]; if (upper.codeUnitAt(0) >= 65 && upper.codeUnitAt(0) <= 90) {
if (item.isNotEmpty) { return upper;
final letter = item[0].toUpperCase();
if (!map.containsKey(letter)) {
map[letter] = i;
}
}
} }
return map; return '#';
} }
String _getLetterAtPosition(double position, double maxHeight) { /// Get the letter at a given scroll fraction (0.0 to 1.0)
if (widget.items.isEmpty || maxHeight <= 0) return ''; String _getLetterAtFraction(double fraction) {
if (widget.items.isEmpty) return '';
// Calculate which item index corresponds to this position final index = (fraction * (widget.items.length - 1)).round().clamp(0, widget.items.length - 1);
final fraction = (position / maxHeight).clamp(0.0, 1.0); if (index < widget.items.length) {
final index = (fraction * (widget.items.length - 1)).round();
if (index >= 0 && index < widget.items.length) {
final item = widget.items[index]; final item = widget.items[index];
if (item.isNotEmpty) { if (item.isNotEmpty) {
return item[0].toUpperCase(); return _normalizeToLetter(item[0]);
} }
} }
return ''; 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) { void _handleDragStart(DragStartDetails details) {
setState(() { setState(() {
_isDragging = true; _isDragging = true;
_dragPosition = details.localPosition.dy; _dragPosition = details.localPosition.dy;
}); });
widget.onDragStateChanged?.call(true); widget.onDragStateChanged?.call(true);
_updateLetterAndScroll(details.localPosition.dy); _scrollToPosition(details.localPosition.dy);
} }
void _handleDragUpdate(DragUpdateDetails details) { void _handleDragUpdate(DragUpdateDetails details) {
setState(() { setState(() {
_dragPosition = details.localPosition.dy; _dragPosition = details.localPosition.dy;
}); });
_updateLetterAndScroll(details.localPosition.dy); _scrollToPosition(details.localPosition.dy);
} }
void _handleDragEnd(DragEndDetails details) { void _handleDragEnd(DragEndDetails details) {
@@ -145,18 +139,35 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
widget.onDragStateChanged?.call(false); widget.onDragStateChanged?.call(false);
} }
void _updateLetterAndScroll(double position) { /// Scroll proportionally based on drag position
void _scrollToPosition(double position) {
final renderBox = context.findRenderObject() as RenderBox?; final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null) { if (renderBox == null || !widget.controller.hasClients) return;
final height = renderBox.size.height;
final letter = _getLetterAtPosition(position, height); // Account for bottom padding in the effective track height
if (letter.isNotEmpty && letter != _currentLetter) { final totalHeight = renderBox.size.height;
setState(() { final effectiveHeight = totalHeight - widget.bottomPadding;
_currentLetter = letter; if (effectiveHeight <= 0) return;
});
_scrollToLetter(letter); // 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 @override
@@ -168,12 +179,12 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
// The scrollable content // The scrollable content
widget.child, widget.child,
// The draggable scrollbar area (right edge) - wider grab area // The draggable scrollbar area (right edge)
Positioned( Positioned(
right: 0, right: 0,
top: 0, top: 0,
bottom: 0, bottom: widget.bottomPadding,
width: 32, // Wider grab area width: 32,
child: GestureDetector( child: GestureDetector(
onVerticalDragStart: _handleDragStart, onVerticalDragStart: _handleDragStart,
onVerticalDragUpdate: _handleDragUpdate, onVerticalDragUpdate: _handleDragUpdate,
@@ -196,11 +207,11 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
final trackHeight = constraints.maxHeight; final trackHeight = constraints.maxHeight;
final thumbHeight = _isDragging ? 60.0 : 40.0; final thumbHeight = _isDragging ? 60.0 : 40.0;
final availableTrack = trackHeight - thumbHeight; final availableTrack = trackHeight - thumbHeight;
final thumbTop = availableTrack * _scrollFraction; final thumbTop = (availableTrack * _scrollFraction).clamp(0.0, availableTrack);
return Stack( return Stack(
children: [ children: [
// Thumb that tracks scroll position - use AnimatedPositioned for smooth motion // Thumb that tracks scroll position
AnimatedPositioned( AnimatedPositioned(
duration: const Duration(milliseconds: 50), duration: const Duration(milliseconds: 50),
curve: Curves.linear, curve: Curves.linear,
@@ -229,7 +240,10 @@ class _LetterScrollbarState extends State<LetterScrollbar> {
if (_isDragging && _currentLetter.isNotEmpty) if (_isDragging && _currentLetter.isNotEmpty)
Positioned( Positioned(
right: 48, right: 48,
top: _dragPosition - 28, top: (_dragPosition - 28).clamp(
0.0,
(context.findRenderObject() as RenderBox?)?.size.height ?? 500 - 56 - widget.bottomPadding,
),
child: Material( child: Material(
elevation: 4, elevation: 4,
color: colorScheme.primaryContainer, color: colorScheme.primaryContainer,
+2 -13
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../providers/music_assistant_provider.dart'; import '../../providers/music_assistant_provider.dart';
import '../../models/media_item.dart' show Audiobook, Chapter; import '../../models/media_item.dart' show Audiobook, Chapter;
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../../utils/duration_formatter.dart';
import '../common/empty_state.dart'; import '../common/empty_state.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -26,18 +27,6 @@ class ChaptersPanel extends StatelessWidget {
required this.onClose, required this.onClose,
}); });
String _formatDuration(int seconds) {
final duration = Duration(seconds: seconds);
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
final secs = duration.inSeconds % 60;
if (hours > 0) {
return '${hours}:${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}
return '${minutes}:${secs.toString().padLeft(2, '0')}';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final chapters = audiobook?.chapters ?? []; final chapters = audiobook?.chapters ?? [];
@@ -147,7 +136,7 @@ class ChaptersPanel extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
subtitle: Text( subtitle: Text(
_formatDuration(chapter.positionMs ~/ 1000), formatDurationSeconds(chapter.positionMs ~/ 1000),
style: TextStyle( style: TextStyle(
color: textColor.withOpacity(0.5), color: textColor.withOpacity(0.5),
fontSize: 12, fontSize: 12,
+72 -35
View File
@@ -6,7 +6,6 @@ import '../../l10n/app_localizations.dart';
import '../../providers/music_assistant_provider.dart'; import '../../providers/music_assistant_provider.dart';
import '../../services/settings_service.dart'; import '../../services/settings_service.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../../theme/theme_provider.dart';
import '../../theme/palette_helper.dart'; import '../../theme/palette_helper.dart';
import '../../constants/timings.dart'; import '../../constants/timings.dart';
import '../global_player_overlay.dart'; import '../global_player_overlay.dart';
@@ -20,12 +19,25 @@ class PlayerRevealOverlay extends StatefulWidget {
final double miniPlayerHeight; final double miniPlayerHeight;
final bool showOnboardingHints; // Show additional hints for first-time users final bool showOnboardingHints; // Show additional hints for first-time users
/// Callback when player is selected for a pending action (e.g., "Play On").
/// If null, selecting a player just switches to that player.
final void Function(dynamic player)? onPlayerSelected;
/// Context hint to display instead of default hints (e.g., "Select player to play album").
final String? contextHint;
/// Icon for context hint (default: play_circle_outline).
final IconData? contextHintIcon;
const PlayerRevealOverlay({ const PlayerRevealOverlay({
super.key, super.key,
required this.onDismiss, required this.onDismiss,
required this.miniPlayerBottom, required this.miniPlayerBottom,
required this.miniPlayerHeight, required this.miniPlayerHeight,
this.showOnboardingHints = false, this.showOnboardingHints = false,
this.onPlayerSelected,
this.contextHint,
this.contextHintIcon,
}); });
@override @override
@@ -41,9 +53,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
Timer? _refreshTimer; Timer? _refreshTimer;
// Track vertical drag for dismissal // Track vertical drag for dismissal
double _dragOffset = 0;
bool _isDragging = false; 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 // Scroll controller for when player list overflows
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
bool _isScrollable = false; bool _isScrollable = false;
@@ -154,13 +168,24 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Trigger single bounce on mini player when cards are ~70% collapsed // Trigger single bounce on mini player when cards are ~70% collapsed
// This creates the effect of cards "landing" on the mini player // This creates the effect of cards "landing" on the mini player
bool bounceFired = false; bool bounceFired = false;
void checkBounce() { void fireBounce() {
if (!bounceFired && _revealController.value < 0.3) { if (!bounceFired) {
bounceFired = true; bounceFired = true;
GlobalPlayerOverlay.triggerBounce(); 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.duration = const Duration(milliseconds: 150);
_revealController.addListener(checkBounce); _revealController.addListener(checkBounce);
_revealController.reverse().then((_) { _revealController.reverse().then((_) {
@@ -201,11 +226,17 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Ignore swipe up when not scrollable - cards should stay fixed // Ignore swipe up when not scrollable - cards should stay fixed
if (delta <= 0) return; if (delta <= 0) return;
// Swiping down = dismiss gesture // Swiping down = dismiss gesture - directly control animation controller
setState(() { _isDragging = true;
_isDragging = true;
_dragOffset += delta; // 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) { void _handleVerticalDragEnd(DragEndDetails details) {
@@ -213,28 +244,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// If not dragging (was scrolling), don't dismiss // If not dragging (was scrolling), don't dismiss
if (!_isDragging) return; if (!_isDragging) return;
_isDragging = false;
// Dismiss if dragged down enough or with enough velocity // Dismiss if dragged past threshold or with enough velocity
if (_dragOffset > 100 || velocity > 500) { if (_revealController.value < _dismissThreshold || velocity > 500) {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
// Reset drag offset before dismiss so animation starts from correct position
setState(() {
_isDragging = false;
_dragOffset = 0;
});
dismiss(); dismiss();
} else { } else {
// Spring back // Spring back to fully revealed
setState(() { _revealController.duration = const Duration(milliseconds: 150);
_isDragging = false; _revealController.forward();
_dragOffset = 0;
});
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -322,7 +346,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
Positioned( Positioned(
left: 12, left: 12,
right: 12, right: 12,
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset, bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8,
child: GestureDetector( child: GestureDetector(
onVerticalDragUpdate: _handleVerticalDragUpdate, onVerticalDragUpdate: _handleVerticalDragUpdate,
onVerticalDragEnd: _handleVerticalDragEnd, onVerticalDragEnd: _handleVerticalDragEnd,
@@ -372,16 +396,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: Material( child: Material(
type: MaterialType.transparency, type: MaterialType.transparency,
child: isOnboarding child: widget.contextHint != null
? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint) ? buildHintRow(
: Column( widget.contextHintIcon ?? Icons.play_circle_outline,
mainAxisSize: MainAxisSize.min, widget.contextHint!,
children: [ )
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync), : isOnboarding
const SizedBox(height: 4), ? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume), : Column(
], mainAxisSize: MainAxisSize.min,
), children: [
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
const SizedBox(height: 4),
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
],
),
), ),
), ),
); );
@@ -425,7 +454,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
textColor: data.cardTextColor, textColor: data.cardTextColor,
onTap: () { onTap: () {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
maProvider.selectPlayer(data.player); if (widget.onPlayerSelected != null) {
widget.onPlayerSelected!(data.player);
} else {
maProvider.selectPlayer(data.player);
}
dismiss(); dismiss();
}, },
onLongPress: () { onLongPress: () {
@@ -478,7 +511,11 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
textColor: data.cardTextColor, textColor: data.cardTextColor,
onTap: () { onTap: () {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
maProvider.selectPlayer(data.player); if (widget.onPlayerSelected != null) {
widget.onPlayerSelected!(data.player);
} else {
maProvider.selectPlayer(data.player);
}
dismiss(); dismiss();
}, },
onLongPress: () { onLongPress: () {
+415 -73
View File
@@ -3,10 +3,13 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../../l10n/app_localizations.dart';
import '../../providers/music_assistant_provider.dart'; import '../../providers/music_assistant_provider.dart';
import '../../models/player.dart'; import '../../models/player.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../../utils/duration_formatter.dart';
import '../common/empty_state.dart'; import '../common/empty_state.dart';
import '../global_player_overlay.dart';
/// Panel that displays the current playback queue with drag-to-reorder /// Panel that displays the current playback queue with drag-to-reorder
/// and swipe-left-to-delete functionality. /// and swipe-left-to-delete functionality.
@@ -25,8 +28,8 @@ class QueuePanel extends StatefulWidget {
final VoidCallback onRefresh; final VoidCallback onRefresh;
final ValueChanged<bool>? onDraggingChanged; final ValueChanged<bool>? onDraggingChanged;
final VoidCallback? onSwipeStart; final VoidCallback? onSwipeStart;
final ValueChanged<double>? onSwipeUpdate; // dx delta from start final ValueChanged<double>? onSwipeUpdate; // dy delta from start (vertical swipe)
final void Function(double velocity, double totalDx)? onSwipeEnd; // velocity and total displacement final void Function(double velocity, double totalDy)? onSwipeEnd; // velocity and total displacement (vertical)
const QueuePanel({ const QueuePanel({
super.key, super.key,
@@ -49,10 +52,19 @@ class QueuePanel extends StatefulWidget {
State<QueuePanel> createState() => _QueuePanelState(); State<QueuePanel> createState() => _QueuePanelState();
} }
class _QueuePanelState extends State<QueuePanel> { class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateMixin {
List<QueueItem> _items = []; List<QueueItem> _items = [];
final GlobalKey _stackKey = GlobalKey(); final GlobalKey _stackKey = GlobalKey();
// Scroll controller to track list position (for swipe-to-close only at top)
final ScrollController _scrollController = ScrollController();
bool _isAtTop = true;
bool _wasAtTopOnSwipeStart = true; // Capture at gesture start
// Transfer dropdown animation
late AnimationController _dropdownController;
bool _showingTransferDropdown = false;
// Drag state // Drag state
int? _dragIndex; int? _dragIndex;
int? _dragStartIndex; int? _dragStartIndex;
@@ -90,6 +102,21 @@ class _QueuePanelState extends State<QueuePanel> {
void initState() { void initState() {
super.initState(); super.initState();
_items = List.from(widget.queue?.items ?? []); _items = List.from(widget.queue?.items ?? []);
_dropdownController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
// Track scroll position for swipe-to-close (only allowed at top)
_scrollController.addListener(_onScroll);
}
void _onScroll() {
final atTop = _scrollController.offset <= 0;
if (atTop != _isAtTop) {
setState(() {
_isAtTop = atTop;
});
}
} }
@override @override
@@ -125,17 +152,13 @@ class _QueuePanelState extends State<QueuePanel> {
@override @override
void dispose() { void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_dropdownController.dispose();
_pendingReorderTimer?.cancel(); _pendingReorderTimer?.cancel();
super.dispose(); super.dispose();
} }
String _formatDuration(Duration? duration) {
if (duration == null) return '';
final totalSeconds = duration.inSeconds;
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
void _handleDelete(QueueItem item, int index) async { void _handleDelete(QueueItem item, int index) async {
// Store item for potential rollback // Store item for potential rollback
@@ -152,9 +175,7 @@ class _QueuePanelState extends State<QueuePanel> {
if (playerId != null) { if (playerId != null) {
try { try {
await widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId); await widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId);
debugPrint('QueuePanel: Delete successful for ${item.track.name}');
} catch (e) { } catch (e) {
debugPrint('QueuePanel: Error deleting queue item: $e');
// Rollback: re-insert the item or refresh from server // Rollback: re-insert the item or refresh from server
// Refresh is safer as queue may have changed // Refresh is safer as queue may have changed
widget.onRefresh(); widget.onRefresh();
@@ -175,12 +196,180 @@ class _QueuePanelState extends State<QueuePanel> {
try { try {
await widget.maProvider.api?.queueCommandClear(playerId); await widget.maProvider.api?.queueCommandClear(playerId);
} catch (e) { } catch (e) {
debugPrint('QueuePanel: Error clearing queue: $e');
// Refresh to restore if failed // Refresh to restore if failed
widget.onRefresh(); widget.onRefresh();
} }
} }
void _openTransferDropdown() {
if (_showingTransferDropdown) return;
setState(() => _showingTransferDropdown = true);
_dropdownController.forward();
}
void _closeTransferDropdown() {
if (!_showingTransferDropdown) return;
_dropdownController.reverse().then((_) {
if (mounted) setState(() => _showingTransferDropdown = false);
});
}
void _handleTransferQueue(BuildContext context) {
if (_showingTransferDropdown) {
_closeTransferDropdown();
} else {
_openTransferDropdown();
}
}
List<Player> _getTargetPlayers() {
// Use selected player (current player) as source, not the queue's player ID
// This ensures correct filtering after queue transfer when player switches
final sourcePlayerId = widget.maProvider.selectedPlayer?.playerId;
if (sourcePlayerId == null) return [];
return widget.maProvider.availablePlayers
.where((p) => p.playerId != sourcePlayerId && p.available)
.toList();
}
Widget _buildTransferDropdown() {
final targetPlayers = _getTargetPlayers();
final colorScheme = Theme.of(context).colorScheme;
// Use a lighter shade of the queue panel background color
final hsl = HSLColor.fromColor(widget.backgroundColor);
final menuBackground = hsl.withLightness((hsl.lightness + 0.04).clamp(0.0, 1.0)).toColor();
// Use theme's onSurface for text (matches library dropdown styling)
final menuTextColor = colorScheme.onSurface;
return Container(
constraints: const BoxConstraints(maxHeight: 280, maxWidth: 220),
decoration: BoxDecoration(
color: menuBackground,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title header (matches library dropdown section headers)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
S.of(context)!.transferTo,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: menuTextColor.withOpacity(0.5),
),
),
),
// Player list
if (targetPlayers.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Text(
S.of(context)!.noOtherPlayersAvailable,
style: TextStyle(color: menuTextColor.withOpacity(0.5), fontSize: 14),
),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 8),
itemCount: targetPlayers.length,
itemBuilder: (context, index) {
final player = targetPlayers[index];
// Status color
Color statusColor;
if (!player.powered) {
statusColor = Colors.grey;
} else if (player.state == 'playing') {
statusColor = Colors.green;
} else {
statusColor = Colors.orange;
}
// Match library dropdown item styling
return InkWell(
onTap: () => _transferToPlayer(player),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(Icons.speaker_rounded, color: menuTextColor, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
player.name,
style: TextStyle(color: menuTextColor),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: statusColor,
),
),
],
),
),
);
},
),
),
],
),
),
);
}
Future<void> _transferToPlayer(Player targetPlayer) async {
final sourcePlayerId = widget.queue?.playerId;
if (sourcePlayerId == null) return;
// Close dropdown first
_closeTransferDropdown();
try {
await widget.maProvider.api?.transferQueue(
sourceQueueId: sourcePlayerId,
targetQueueId: targetPlayer.playerId,
autoPlay: true,
);
// Clear source player's track cache since its queue was transferred away
widget.maProvider.clearPlayerTrackCache(sourcePlayerId);
// Force collapse to mini player (closes queue instantly + collapses)
GlobalPlayerOverlay.forceCollapsePlayer();
// Switch to the target player - mini player will update to show new player
widget.maProvider.selectPlayer(targetPlayer);
} catch (e) {
// Error transferring queue - handled silently
}
}
void _resetSwipeState() { void _resetSwipeState() {
_swipeStart = null; _swipeStart = null;
_swipeLast = null; _swipeLast = null;
@@ -250,6 +439,29 @@ class _QueuePanelState extends State<QueuePanel> {
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0; return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
} }
double _calculateAverageVelocityVertical() {
if (_velocitySamples.length < 2) return 0.0;
// Use weighted average of recent samples (more recent = higher weight)
double totalVelocity = 0.0;
double totalWeight = 0.0;
for (int i = 1; i < _velocitySamples.length; i++) {
final prev = _velocitySamples[i - 1];
final curr = _velocitySamples[i];
final dt = curr.timeMs - prev.timeMs;
if (dt > 0 && dt < 100) { // Only use samples within 100ms
final dy = curr.position.dy - prev.position.dy;
final velocity = (dy / dt) * 1000; // px/s
final weight = i.toDouble(); // Later samples get higher weight
totalVelocity += velocity * weight;
totalWeight += weight;
}
}
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
}
void _startDrag(int index, BuildContext itemContext, Offset globalPosition) { void _startDrag(int index, BuildContext itemContext, Offset globalPosition) {
if (_dragIndex != null) return; if (_dragIndex != null) return;
@@ -343,11 +555,9 @@ class _QueuePanelState extends State<QueuePanel> {
final playerId = widget.queue?.playerId; final playerId = widget.queue?.playerId;
// API uses relative shift: positive = down, negative = up // API uses relative shift: positive = down, negative = up
final posShift = newIndex - originalIndex; final posShift = newIndex - originalIndex;
debugPrint('QueuePanel: Moving ${item.track.name} from $originalIndex to $newIndex (shift: $posShift, queueItemId: ${item.queueItemId})');
if (playerId != null) { if (playerId != null) {
try { try {
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift); await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift);
debugPrint('QueuePanel: Move API call completed successfully');
// Allow updates again after a delay for server state to propagate // Allow updates again after a delay for server state to propagate
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () { _pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
if (mounted) { if (mounted) {
@@ -357,7 +567,6 @@ class _QueuePanelState extends State<QueuePanel> {
} }
}); });
} catch (e) { } catch (e) {
debugPrint('QueuePanel: Move API error: $e');
// Clear pending state immediately on error // Clear pending state immediately on error
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -368,7 +577,6 @@ class _QueuePanelState extends State<QueuePanel> {
widget.onRefresh(); widget.onRefresh();
} }
} else { } else {
debugPrint('QueuePanel: playerId is null, cannot move');
// Clear pending state // Clear pending state
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -415,15 +623,71 @@ class _QueuePanelState extends State<QueuePanel> {
try { try {
await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId); await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId);
} catch (e) { } catch (e) {
debugPrint('QueuePanel: Error playing index: $e'); // Error playing index - clear optimistic state
if (mounted) {
setState(() {
_optimisticCurrentIndex = null;
});
}
}
// Note: didUpdateWidget clears _optimisticCurrentIndex when server state catches up
}
void _handleMoveToPlayNext(QueueItem item, int index, bool isCurrentItem) async {
if (isCurrentItem) return;
final playerId = widget.queue?.playerId;
if (playerId == null) return;
// Get current index (where the currently playing track is)
final currentIndex = _optimisticCurrentIndex ?? widget.queue!.currentIndex ?? 0;
// Target position is right after the current track
final targetIndex = currentIndex + 1;
// If already at target position, nothing to do
if (index == targetIndex) {
// Haptic to confirm it's already in position
HapticFeedback.lightImpact();
return;
} }
// Clear optimistic state after delay - server state will take over // Calculate the position shift for the API
await Future.delayed(const Duration(milliseconds: 1500)); final posShift = targetIndex - index;
if (mounted) {
setState(() { // Cancel any existing timer to prevent stacking
_optimisticCurrentIndex = null; _pendingReorderTimer?.cancel();
// Optimistic update - move item in local list
setState(() {
final movedItem = _items.removeAt(index);
_items.insert(targetIndex > index ? targetIndex - 1 : targetIndex, movedItem);
_pendingReorder = true;
});
// Haptic feedback
HapticFeedback.mediumImpact();
// Call API
try {
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift);
// Allow updates again after a delay for server state to propagate
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
if (mounted) {
setState(() {
_pendingReorder = false;
});
}
}); });
} catch (e) {
// Clear pending state immediately on error
if (mounted) {
setState(() {
_pendingReorder = false;
});
}
// Refresh queue from server to get correct state
widget.onRefresh();
} }
} }
@@ -450,17 +714,19 @@ class _QueuePanelState extends State<QueuePanel> {
_swipeLocked = false; _swipeLocked = false;
_velocitySamples.clear(); _velocitySamples.clear();
_addVelocitySample(event.position, _swipeLastTime!); _addVelocitySample(event.position, _swipeLastTime!);
// Capture scroll position at gesture start - only allow close if already at top
_wasAtTopOnSwipeStart = _isAtTop;
} else if (startedInEdgeZone) { } else if (startedInEdgeZone) {
// Clear swipe state for edge touches // Clear swipe state for edge touches
_swipeStart = null; _swipeStart = null;
} }
}, },
onPointerMove: (event) { onPointerMove: (event) {
// Ignore swipes from edge zone (let Android back gesture handle it) // Ignore swipes while dragging a queue item
if (_swipeStart == null || _dragIndex != null) return; if (_swipeStart == null || _dragIndex != null) return;
final dx = event.position.dx - _swipeStart!.dx; final dx = (event.position.dx - _swipeStart!.dx).abs();
final dy = (event.position.dy - _swipeStart!.dy).abs(); final dy = event.position.dy - _swipeStart!.dy;
final now = DateTime.now().millisecondsSinceEpoch; final now = DateTime.now().millisecondsSinceEpoch;
// Track all move events for velocity calculation // Track all move events for velocity calculation
@@ -470,31 +736,31 @@ class _QueuePanelState extends State<QueuePanel> {
// Once direction is locked, maintain it (prevents accidental cancellation) // Once direction is locked, maintain it (prevents accidental cancellation)
if (_swipeLocked && _isSwiping) { if (_swipeLocked && _isSwiping) {
widget.onSwipeUpdate?.call(dx.clamp(0.0, double.infinity)); widget.onSwipeUpdate?.call(dy.clamp(0.0, double.infinity));
return; return;
} }
// Check if this is a horizontal swipe (reduced tolerance: dx > dy * 1.2) // Check if this is a vertical swipe (dy > dx * 1.2)
final isHorizontal = dx.abs() > _swipeMinDistance && dx.abs() > dy * 1.2; final isVertical = dy.abs() > _swipeMinDistance && dy.abs() > dx * 1.2;
if (isHorizontal && dx > 0) { if (isVertical && dy > 0 && _wasAtTopOnSwipeStart) {
// Horizontal swipe right - close gesture // Vertical swipe down - close gesture (only when was at top when gesture started)
if (!_isSwiping) { if (!_isSwiping) {
_isSwiping = true; _isSwiping = true;
_swipeLocked = true; // Lock direction once swipe starts _swipeLocked = true; // Lock direction once swipe starts
widget.onSwipeStart?.call(); widget.onSwipeStart?.call();
} }
widget.onSwipeUpdate?.call(dx); widget.onSwipeUpdate?.call(dy);
} else if (!_swipeLocked && dx.abs() > _swipeMinDistance) { } else if (!_swipeLocked && dy.abs() > _swipeMinDistance) {
// Direction established as vertical - don't start swipe // Direction established as horizontal - don't start swipe
_swipeLocked = true; // Lock as non-swipe _swipeLocked = true; // Lock as non-swipe
} }
}, },
onPointerUp: (event) { onPointerUp: (event) {
if (_isSwiping && _swipeStart != null) { if (_isSwiping && _swipeStart != null) {
final velocity = _calculateAverageVelocity(); final velocity = _calculateAverageVelocityVertical();
final totalDx = event.position.dx - _swipeStart!.dx; final totalDy = event.position.dy - _swipeStart!.dy;
widget.onSwipeEnd?.call(velocity, totalDx); widget.onSwipeEnd?.call(velocity, totalDy);
} }
_resetSwipeState(); _resetSwipeState();
}, },
@@ -506,41 +772,55 @@ class _QueuePanelState extends State<QueuePanel> {
}, },
child: Container( child: Container(
color: widget.backgroundColor, color: widget.backgroundColor,
child: Column( child: Stack(
children: [ children: [
// Header // Main content column
Container( Column(
padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16), children: [
child: Row( // Header
children: [ Container(
IconButton( padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16),
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md), child: Row(
onPressed: widget.onClose, children: [
padding: Spacing.paddingAll12, IconButton(
icon: Icon(Icons.keyboard_arrow_down_rounded, color: widget.textColor, size: IconSizes.lg),
onPressed: _showingTransferDropdown ? _closeTransferDropdown : widget.onClose,
padding: Spacing.paddingAll12,
),
const SizedBox(width: 8),
Text(
'Queue',
style: TextStyle(
color: widget.textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
// Transfer queue button
IconButton(
icon: Icon(
_showingTransferDropdown ? Icons.close : Icons.swap_horiz_rounded,
color: _showingTransferDropdown ? widget.primaryColor : widget.textColor.withOpacity(0.7),
size: IconSizes.sm,
),
onPressed: () => _handleTransferQueue(context),
padding: Spacing.paddingAll12,
tooltip: S.of(context)!.transferQueueToPlayer,
),
// Clear queue button
IconButton(
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: _handleClearQueue,
padding: Spacing.paddingAll12,
tooltip: S.of(context)!.clearQueue,
),
],
), ),
const Spacer(), ),
Text(
'Queue',
style: TextStyle(
color: widget.textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
// Clear queue button
IconButton(
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: _handleClearQueue,
padding: Spacing.paddingAll12,
tooltip: 'Clear queue',
),
],
),
),
// Queue content // Queue content
Expanded( Expanded(
child: widget.isLoading child: widget.isLoading
? Center(child: CircularProgressIndicator(color: widget.primaryColor)) ? Center(child: CircularProgressIndicator(color: widget.primaryColor))
: widget.queue == null || _items.isEmpty : widget.queue == null || _items.isEmpty
@@ -585,7 +865,49 @@ class _QueuePanelState extends State<QueuePanel> {
], ],
), ),
), ),
),
],
), ),
// Floating dropdown overlay for transfer
if (_showingTransferDropdown) ...[
// Tap-to-dismiss backdrop
Positioned.fill(
child: GestureDetector(
onTap: _closeTransferDropdown,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
// Dropdown menu with open/close animation
Positioned(
top: widget.topPadding + 52, // Below header
right: 56, // Aligned with transfer button
child: AnimatedBuilder(
animation: CurvedAnimation(
parent: _dropdownController,
curve: Curves.easeInOut,
),
builder: (context, child) {
final value = _dropdownController.value;
final curvedValue = Curves.easeInOut.transform(value);
return ClipRect(
child: Align(
alignment: Alignment.topCenter,
widthFactor: 1.0,
heightFactor: curvedValue,
child: Opacity(
opacity: curvedValue,
child: child,
),
),
);
},
child: _buildTransferDropdown(),
),
),
],
], ],
), ),
), ),
@@ -598,6 +920,7 @@ class _QueuePanelState extends State<QueuePanel> {
return ListView.builder( return ListView.builder(
key: const PageStorageKey('queue_list'), key: const PageStorageKey('queue_list'),
controller: _scrollController,
padding: Spacing.paddingH8, padding: Spacing.paddingH8,
// Disable scrolling while dragging to prevent gesture conflict // Disable scrolling while dragging to prevent gesture conflict
physics: _dragIndex != null physics: _dragIndex != null
@@ -621,8 +944,19 @@ class _QueuePanelState extends State<QueuePanel> {
Widget _buildDismissibleItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem) { Widget _buildDismissibleItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem) {
return Dismissible( return Dismissible(
key: ValueKey(item.queueItemId), key: ValueKey(item.queueItemId),
direction: DismissDirection.endToStart, direction: isCurrentItem ? DismissDirection.none : DismissDirection.horizontal,
// Swipe right (startToEnd) = move to play next
background: Container( background: Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 20),
decoration: BoxDecoration(
color: widget.primaryColor,
borderRadius: BorderRadius.zero,
),
child: const Icon(Icons.skip_next_rounded, color: Colors.white, size: 28),
),
// Swipe left (endToStart) = delete
secondaryBackground: Container(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20), padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -634,8 +968,15 @@ class _QueuePanelState extends State<QueuePanel> {
confirmDismiss: (direction) async { confirmDismiss: (direction) async {
// Block dismissal if swipe started from screen edge (Android back gesture) // Block dismissal if swipe started from screen edge (Android back gesture)
if (lastTouchWasInEdgeZone) return false; if (lastTouchWasInEdgeZone) return false;
// Don't allow dismissing the currently playing item
return !isCurrentItem; if (direction == DismissDirection.startToEnd) {
// Swipe right = move to play next position
_handleMoveToPlayNext(item, index, isCurrentItem);
return false; // Don't dismiss - item stays in queue (just moved)
} else {
// Swipe left = delete
return true;
}
}, },
onDismissed: (direction) => _handleDelete(item, index), onDismissed: (direction) => _handleDelete(item, index),
child: _buildQueueItemWithDragHandle(item, index, isCurrentItem, isPastItem), child: _buildQueueItemWithDragHandle(item, index, isCurrentItem, isPastItem),
@@ -677,7 +1018,7 @@ class _QueuePanelState extends State<QueuePanel> {
Widget _buildQueueItemContent(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {Widget? dragHandle}) { Widget _buildQueueItemContent(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {Widget? dragHandle}) {
final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80); final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80);
final duration = _formatDuration(item.track.duration); final duration = formatDurationOrEmpty(item.track.duration);
return RepaintBoundary( return RepaintBoundary(
child: Opacity( child: Opacity(
@@ -775,3 +1116,4 @@ class _VelocitySample {
_VelocitySample(this.position, this.timeMs); _VelocitySample(this.position, this.timeMs);
} }
+1
View File
@@ -212,6 +212,7 @@ Future<void> showPlayerPickerSheet({
context: context, context: context,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
isScrollControlled: true, isScrollControlled: true,
useRootNavigator: true, // Required for overlays like QueuePanel
builder: (context) => PlayerPickerSheet( builder: (context) => PlayerPickerSheet(
title: title, title: title,
players: players, players: players,
+1 -3
View File
@@ -245,14 +245,12 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
// Show snackbar for visual feedback // Show snackbar for visual feedback
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Syncing ${player.name}...'), content: Text(S.of(context)!.syncingPlayer(player.name)),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
// Long-press to sync/unsync player // 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); maProvider.togglePlayerSync(player.playerId);
}, },
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
+74 -50
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
@@ -5,10 +6,12 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../screens/playlist_details_screen.dart'; import '../screens/playlist_details_screen.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import 'provider_icon.dart';
class PlaylistCard extends StatelessWidget { class PlaylistCard extends StatefulWidget {
final Playlist playlist; final Playlist playlist;
final VoidCallback? onTap; final VoidCallback? onTap;
final String? heroTagSuffix; final String? heroTagSuffix;
@@ -22,31 +25,47 @@ class PlaylistCard extends StatelessWidget {
this.imageCacheSize, this.imageCacheSize,
}); });
@override
State<PlaylistCard> createState() => _PlaylistCardState();
}
class _PlaylistCardState extends State<PlaylistCard> {
bool _isNavigating = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256); final imageUrl = maProvider.api?.getImageUrl(widget.playlist, size: 256);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : ''; final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
final cacheSize = imageCacheSize ?? 256; final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary( return RepaintBoundary(
child: GestureDetector( child: GestureDetector(
onTap: onTap ?? () { onTap: widget.onTap ?? () {
// Update adaptive colors immediately on tap // Prevent double-tap navigation
updateAdaptiveColorsFromImage(context, imageUrl); if (_isNavigating) return;
_isNavigating = true;
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push( Navigator.push(
context, context,
FadeSlidePageRoute( FadeSlidePageRoute(
child: PlaylistDetailsScreen( child: PlaylistDetailsScreen(
playlist: playlist, playlist: widget.playlist,
heroTagSuffix: heroTagSuffix, heroTagSuffix: widget.heroTagSuffix,
initialImageUrl: imageUrl, initialImageUrl: imageUrl,
), ),
), ),
); ).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
}, },
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -55,59 +74,64 @@ class PlaylistCard extends StatelessWidget {
AspectRatio( AspectRatio(
aspectRatio: 1.0, aspectRatio: 1.0,
child: Hero( child: Hero(
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + suffix, tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
child: Container( child: Stack(
color: colorScheme.surfaceContainerHighest, fit: StackFit.expand,
child: imageUrl != null children: [
? CachedNetworkImage( Container(
imageUrl: imageUrl, color: colorScheme.surfaceContainerHighest,
fit: BoxFit.cover, child: imageUrl != null
memCacheWidth: cacheSize, ? CachedNetworkImage(
memCacheHeight: cacheSize, imageUrl: imageUrl,
fadeInDuration: Duration.zero, fit: BoxFit.cover,
fadeOutDuration: Duration.zero, memCacheWidth: cacheSize,
placeholder: (context, url) => const SizedBox(), memCacheHeight: cacheSize,
errorWidget: (context, url, error) => Center( fadeInDuration: Duration.zero,
child: Icon( fadeOutDuration: Duration.zero,
Icons.playlist_play_rounded, placeholder: (context, url) => const SizedBox(),
size: 64, errorWidget: (context, url, error) => Center(
color: colorScheme.onSurfaceVariant, child: Icon(
Icons.playlist_play_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
Icons.playlist_play_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
) // Provider icon overlay
: Center( if (widget.playlist.providerMappings?.isNotEmpty == true)
child: Icon( ProviderIconOverlay(
Icons.playlist_play_rounded, domain: widget.playlist.providerMappings!.first.providerDomain,
size: 64, ),
color: colorScheme.onSurfaceVariant, ],
),
),
), ),
), ),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Playlist title // Playlist title
Hero( // PERF: Removed Hero - text animations provide minimal benefit but add overhead
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + suffix, Text(
child: Material( widget.playlist.name,
color: Colors.transparent, maxLines: 1,
child: Text( overflow: TextOverflow.ellipsis,
playlist.name, style: textTheme.titleSmall?.copyWith(
maxLines: 1, color: colorScheme.onSurface,
overflow: TextOverflow.ellipsis, fontWeight: FontWeight.w500,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
), ),
), ),
// Owner/track count // Owner/track count
Text( Text(
playlist.owner ?? (playlist.trackCount != null ? '${playlist.trackCount} tracks' : ''), widget.playlist.owner ?? (widget.playlist.trackCount != null ? '${widget.playlist.trackCount} tracks' : ''),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
+74 -50
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
@@ -6,10 +7,12 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../screens/podcast_detail_screen.dart'; import '../screens/podcast_detail_screen.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import 'provider_icon.dart';
class PodcastCard extends StatelessWidget { class PodcastCard extends StatefulWidget {
final MediaItem podcast; final MediaItem podcast;
final VoidCallback? onTap; final VoidCallback? onTap;
final String? heroTagSuffix; final String? heroTagSuffix;
@@ -25,34 +28,50 @@ class PodcastCard extends StatelessWidget {
this.imageCacheSize, this.imageCacheSize,
}); });
@override
State<PodcastCard> createState() => _PodcastCardState();
}
class _PodcastCardState extends State<PodcastCard> {
bool _isNavigating = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
// Use provider's getPodcastImageUrl which includes iTunes cache // Use provider's getPodcastImageUrl which includes iTunes cache
final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 256); final imageUrl = maProvider.getPodcastImageUrl(widget.podcast, size: 256);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : ''; final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
// PERF: Use appropriate cache size based on display size // PERF: Use appropriate cache size based on display size
final cacheSize = imageCacheSize ?? 256; final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary( return RepaintBoundary(
child: GestureDetector( child: GestureDetector(
onTap: onTap ?? () { onTap: widget.onTap ?? () {
// Update adaptive colors immediately on tap // Prevent double-tap navigation
updateAdaptiveColorsFromImage(context, imageUrl); if (_isNavigating) return;
_isNavigating = true;
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push( Navigator.push(
context, context,
FadeSlidePageRoute( FadeSlidePageRoute(
child: PodcastDetailScreen( child: PodcastDetailScreen(
podcast: podcast, podcast: widget.podcast,
heroTagSuffix: heroTagSuffix, heroTagSuffix: widget.heroTagSuffix,
initialImageUrl: imageUrl, initialImageUrl: imageUrl,
), ),
), ),
); ).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
}, },
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -61,59 +80,64 @@ class PodcastCard extends StatelessWidget {
AspectRatio( AspectRatio(
aspectRatio: 1.0, aspectRatio: 1.0,
child: Hero( child: Hero(
tag: HeroTags.podcastCover + (podcast.uri ?? podcast.itemId) + suffix, tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
child: Container( child: Stack(
color: colorScheme.surfaceVariant, fit: StackFit.expand,
child: imageUrl != null children: [
? CachedNetworkImage( Container(
imageUrl: imageUrl, color: colorScheme.surfaceVariant,
fit: BoxFit.cover, child: imageUrl != null
memCacheWidth: cacheSize, ? CachedNetworkImage(
memCacheHeight: cacheSize, imageUrl: imageUrl,
fadeInDuration: Duration.zero, fit: BoxFit.cover,
fadeOutDuration: Duration.zero, memCacheWidth: cacheSize,
placeholder: (context, url) => const SizedBox(), memCacheHeight: cacheSize,
errorWidget: (context, url, error) => Center( fadeInDuration: Duration.zero,
child: Icon( fadeOutDuration: Duration.zero,
MdiIcons.podcast, placeholder: (context, url) => const SizedBox(),
size: 64, errorWidget: (context, url, error) => Center(
color: colorScheme.onSurfaceVariant, child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
) // Provider icon overlay
: Center( if (widget.podcast.providerMappings?.isNotEmpty == true)
child: Icon( ProviderIconOverlay(
MdiIcons.podcast, domain: widget.podcast.providerMappings!.first.providerDomain,
size: 64, ),
color: colorScheme.onSurfaceVariant, ],
),
),
), ),
), ),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Podcast title // Podcast title
Hero( // PERF: Removed Hero - text animations provide minimal benefit but add overhead
tag: HeroTags.podcastTitle + (podcast.uri ?? podcast.itemId) + suffix, Text(
child: Material( widget.podcast.name,
color: Colors.transparent, maxLines: 1,
child: Text( overflow: TextOverflow.ellipsis,
podcast.name, style: textTheme.titleSmall?.copyWith(
maxLines: 1, color: colorScheme.onSurface,
overflow: TextOverflow.ellipsis, fontWeight: FontWeight.w500,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
), ),
), ),
// Podcast author (if available) // Podcast author (if available)
Text( Text(
_getAuthor(podcast), _getAuthor(widget.podcast),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
+85
View File
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart';
/// Displays a provider icon (SVG) from Music Assistant.
///
/// Shows a small icon in a rounded container, typically used as an overlay
/// on album art to indicate which provider the item comes from.
class ProviderIcon extends StatelessWidget {
/// The provider domain (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Size of the icon container
final double size;
/// Whether to use dark mode icon variant
final bool? isDark;
const ProviderIcon({
super.key,
required this.domain,
this.size = 20,
this.isDark,
});
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final brightness = Theme.of(context).brightness;
final useDark = isDark ?? brightness == Brightness.dark;
final svgString = maProvider.getProviderIconSvg(domain, isDark: useDark);
if (svgString == null || svgString.isEmpty) {
return const SizedBox.shrink();
}
return SvgPicture.string(
svgString,
width: size,
height: size,
fit: BoxFit.contain,
);
}
}
/// A positioned provider icon overlay for use in Stack widgets.
///
/// Typically positioned in a corner of album art.
class ProviderIconOverlay extends StatelessWidget {
/// The provider domain (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Size of the icon container
final double size;
/// Position from the edges
final double margin;
const ProviderIconOverlay({
super.key,
required this.domain,
this.size = 20,
this.margin = 4,
});
@override
Widget build(BuildContext context) {
// Check if provider icons are enabled in settings
if (!SettingsService.getShowProviderIconsSync()) {
return const SizedBox.shrink();
}
return Positioned(
right: margin,
bottom: margin,
child: ProviderIcon(
domain: domain,
size: size,
),
);
}
}
+27 -9
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -5,8 +6,9 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../constants/timings.dart';
class RadioStationCard extends StatelessWidget { class RadioStationCard extends StatefulWidget {
final MediaItem radioStation; final MediaItem radioStation;
final VoidCallback? onTap; final VoidCallback? onTap;
final String? heroTagSuffix; final String? heroTagSuffix;
@@ -20,25 +22,41 @@ class RadioStationCard extends StatelessWidget {
this.imageCacheSize, this.imageCacheSize,
}); });
@override
State<RadioStationCard> createState() => _RadioStationCardState();
}
class _RadioStationCardState extends State<RadioStationCard> {
bool _isTapping = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.api?.getImageUrl(radioStation, size: 256); final imageUrl = maProvider.api?.getImageUrl(widget.radioStation, size: 256);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : ''; final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
final cacheSize = imageCacheSize ?? 256; final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary( return RepaintBoundary(
child: GestureDetector( child: GestureDetector(
onTap: onTap ?? () { onTap: widget.onTap ?? () {
// Prevent double-tap actions
if (_isTapping) return;
_isTapping = true;
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
// Play the radio station on selected player // Play the radio station on selected player
final selectedPlayer = maProvider.selectedPlayer; final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) { if (selectedPlayer != null) {
maProvider.api?.playRadioStation(selectedPlayer.playerId, radioStation); maProvider.api?.playRadioStation(selectedPlayer.playerId, widget.radioStation);
} }
// Reset after debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isTapping = false;
});
}, },
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -47,7 +65,7 @@ class RadioStationCard extends StatelessWidget {
AspectRatio( AspectRatio(
aspectRatio: 1.0, aspectRatio: 1.0,
child: Hero( child: Hero(
tag: HeroTags.radioCover + (radioStation.uri ?? radioStation.itemId) + suffix, tag: HeroTags.radioCover + (widget.radioStation.uri ?? widget.radioStation.itemId) + suffix,
child: ClipOval( child: ClipOval(
child: Container( child: Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
@@ -84,11 +102,11 @@ class RadioStationCard extends StatelessWidget {
SizedBox( SizedBox(
height: 36, // Fixed height for 2 lines of text height: 36, // Fixed height for 2 lines of text
child: Hero( child: Hero(
tag: HeroTags.radioTitle + (radioStation.uri ?? radioStation.itemId) + suffix, tag: HeroTags.radioTitle + (widget.radioStation.uri ?? widget.radioStation.itemId) + suffix,
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: Text( child: Text(
radioStation.name, widget.radioStation.name,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center, textAlign: TextAlign.center,
+1252
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,7 +1,7 @@
name: ensemble name: ensemble
description: A minimalistic Music Assistant client built with Flutter description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none' publish_to: 'none'
version: 2.8.7-beta+45 version: 2.9.1-beta+49
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -17,6 +17,7 @@ dependencies:
material_design_icons_flutter: ^7.0.7296 material_design_icons_flutter: ^7.0.7296
dynamic_color: 1.6.8 dynamic_color: 1.6.8
palette_generator: ^0.3.3+3 palette_generator: ^0.3.3+3
flutter_svg: ^2.0.10+1
# State management # State management
provider: ^6.1.1 provider: ^6.1.1