Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Keep the comma-separated author handling.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also fixes stale comment reference to library_provider.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Matches the favorite functionality in PlaylistDetailsScreen.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Temporary debug visualization to help understand spacing.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This eliminates the visual jerk when expanding the player.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This eliminates the jank from mixed animation modes.

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

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

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

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

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

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

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

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

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

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

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

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

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

Album art and icons now align consistently across all items.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #31

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Nav bar should be more neutral than player card.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bump version to 2.8.4-beta+41

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

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

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

Bump version to 2.8.3-beta+40

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:27:46 +00:00
Your NameandClaude Opus 4.5 b15f10ade9 Bump version to 2.8.2-beta+39
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 14:18:41 +00:00
Your NameandClaude Opus 4.5 b9c7bc0e2a Fix Tailscale auth detection (issue #38)
Add .ts.net to _isLocalIpAddress() check so Tailscale URLs get the same
redirect-safe HTTP handling as local IPs. This fixes auth detection
failing to show username/password fields for Tailscale connections.

Fixes #38

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 14:03:17 +00:00
Your NameandClaude Opus 4.5 aa74fe96da Bump version to 2.8.1-beta+38
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:15:07 +00:00
Your NameandClaude Opus 4.5 3c296e743d Merge feature/provider-filter-sync: Add provider and player filter sync
Syncs provider_filter and player_filter from Music Assistant user settings.
Filters library content, search results, and available players based on
the user's MA profile configuration.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:09:57 +00:00
Your NameandClaude Opus 4.5 79cadff853 Add player filter and debug logging for recent albums
- Sync player_filter from MA user settings (like provider_filter)
- Apply provider filtering to recent albums
- Add debug logging to diagnose recently_played_items API response

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 12:39:53 +00:00
Your NameandClaude Opus 4.5 19b653a05c Add player_filter sync from MA user settings
Syncs player_filter from Music Assistant auth/me endpoint alongside
the existing provider_filter. Filters which players are visible to
the user based on their MA profile settings.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 12:19:20 +00:00
Your Name 50e567c411 fix: Make favoriteOnly nullable in getAudiobooks wrapper 2026-01-05 11:59:31 +00:00
Your NameandClaude Opus 4.5 26e2c12f8c feat: Add provider filter sync from Music Assistant
- Read provider_filter from auth/me response
- Store and apply filter in MusicAssistantProvider
- Filter library items (artists, albums, tracks, radio, podcasts)
- Filter search results
- Add wrapper methods for playlists and audiobooks with filtering
- Update screens to use filtered wrapper methods

Empty filter = all providers visible (no filtering)
Non-empty filter = only show items from allowed providers

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 11:47:43 +00:00
Your NameandClaude Opus 4.5 de8797a48d Invalidate detail screen caches on library changes
When adding/removing items from library, invalidate relevant caches
to prevent stale inLibrary status in detail screens:

- Albums: invalidate artist albums, home album caches
- Tracks: invalidate all album/playlist tracks caches
- Artists: invalidate home artist caches

Fix stale database cache issue:
- Add clearCacheForType() - clears cache type before re-syncing
- Add markCachedItemDeleted() - marks specific item as deleted
- Call markCachedItemDeleted() immediately when removing from library
- This ensures removed items don't reappear from database cache

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 10:56:24 +00:00
Your NameandClaude Opus 4.5 e1acdb27b3 Fix: Use forceLibrarySync when artist filter setting changes
loadLibrary() loads from cache first then syncs in background,
causing the UI to show stale data until manual refresh.

forceLibrarySync() forces an immediate sync, bypassing the 5-minute
cache freshness check and ensuring the new filter is applied immediately.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 02:42:47 +00:00
Your NameandClaude Opus 4.5 dbf066c692 Use API-level filtering for artist library instead of UI filtering
Changed the artist filter setting to use the Music Assistant API's
built-in albumArtistsOnly parameter instead of filtering in the UI.

When "Show only artists with albums" is enabled:
- sync_service.dart passes albumArtistsOnly: true to getArtists()
- library_provider.dart passes albumArtistsOnly: true to getArtists()
- Settings screen triggers loadLibrary() when setting changes

Removed the unreliable UI-level filtering that was causing the Selector
widget to not properly rebuild when the setting changed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 02:28:40 +00:00
Your NameandClaude Opus 4.5 f5445db8d4 Fix artist filter toggle not triggering UI rebuild
The Selector widget was blocking rebuilds because it only watched
provider data, but _showOnlyArtistsWithAlbums is a local state
variable. Added a ValueKey that includes the setting value to force
Selector to rebuild when the setting changes.

Also made the settings save async and added verification logging.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 02:12:22 +00:00
Your Name 038df11112 fix: reload artist filter on tab change and return from settings 2026-01-05 01:54:19 +00:00
Your Name 63d857ca2c fix: read artist filter setting fresh on each tab view 2026-01-05 01:38:19 +00:00
Your Name c382822f72 fix: reload artist filter setting when returning to library 2026-01-05 01:25:05 +00:00
Your Name 1d37502f44 debug: add more logging for artist filter setting 2026-01-05 01:22:26 +00:00
Your Name 8396078f94 debug: use DebugLogger for artist filter logging 2026-01-05 01:08:11 +00:00
Your Name b051e5af0c debug: add in-app debug banner for artist filter 2026-01-05 01:06:11 +00:00
Your Name f0bce58cfa debug: add logging for artist filter 2026-01-05 01:04:20 +00:00
Your NameandClaude Opus 4.5 c563b1ffaa feat: add podcast action buttons and artist filter setting
Podcast Detail Screen:
- Added action buttons row matching album/artist detail screens
- Play button plays most recent episode
- Play on... button shows device selector for most recent episode
- Library button remains for add/remove from library

Settings:
- Added new "Library" section in settings
- Added "Show only artists with albums" toggle
- When enabled, hides artists that have no albums in library

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:49:08 +00:00
Your NameandClaude Opus 4.5 8876a8ba83 fix: resolve undefined maProvider compile error
Changed maProvider.getImageUrl to context.read<MusicAssistantProvider>().getImageUrl
at line 620 where maProvider was no longer defined after switching from context.watch()
to context.select() for Hero animation performance.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:28:07 +00:00
Your NameandClaude Opus 4.5 6e5ab73e8e Fix Hero animations: context.select, memCacheWidth, precaching
Major Hero animation performance improvements based on deep audit:

1. Fix context.watch → context.select (CRITICAL)
   - podcast_detail_screen: Changed from context.watch() to context.select()
     to prevent rebuilds during Hero animation when provider updates
   - artist_details_screen: Delayed provider listener attachment until
     after Hero animation completes (350ms)

2. Add memCacheWidth/memCacheHeight consistently
   - podcast library list view: Added 256x256 cache
   - podcast library grid view: Added 256x256 cache
   - podcast_detail_screen: Added 256x256 cache
   - audiobook_detail_screen: Added 256x256 cache
   - artist_details_screen: Added 256x256 cache + placeholder

3. Fix album_details_screen Hero structure
   - Changed from Container(BoxDecoration) to ClipRRect → Container pattern
   - Matches source (album_card.dart) structure exactly
   - Fixed borderRadius 16 → 12 to match source
   - Added memCacheWidth 256 and zero fade durations

4. Add image precaching to audiobook_row
   - Added _precacheAudiobookImages() method
   - Precaches first 10 visible items after data loads
   - Matches pattern used by album_row (which works smoothly)

Root causes identified by 5 parallel subagents:
- context.watch() caused full rebuilds during animation
- Missing memCacheWidth caused inconsistent image decode sizes
- Mismatched Hero widget structures between source/destination
- Missing precaching meant images decoded during animation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:16:49 +00:00
Your NameandClaude Opus 4.5 206ab7bff0 Revert background colors and fix search podcast Hero animation
- Reverted colorScheme.surface back to colorScheme.background in 11 files
  (user reported the new color was too light/grey)

- Fixed search screen podcast Hero structure to match library/detail pattern:
  - Changed widget order from Container → ClipRRect to ClipRRect → Container
  - Changed borderRadius from 8 to 16 (matching library/detail)
  - Changed color from surfaceVariant to surfaceContainerHighest
  - Removed memCacheWidth constraints to match library behavior
  - This ensures smooth Hero animation when navigating from search

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:54:50 +00:00
Your NameandClaude Opus 4.5 8d07752a03 Audit: Fix deprecated APIs and optimize scroll performance
- Replace colorScheme.background with colorScheme.surface (Material 3)
- Add memCacheWidth/memCacheHeight to CachedNetworkImage instances
- Create AUDIT_REPORT.md with comprehensive findings
- Create AUDIT_SUMMARY.md with changes applied

Files modified: 11 screen files
Deprecated API fixes: 20 instances
Image cache optimizations: 6 locations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:29:04 +00:00
Your NameandClaude Opus 4.5 0843b8f9ce Remove verbose debug logging
Cleaned up excessive logging throughout the codebase:
- Removed library add/remove action logs
- Removed media event subscription logs
- Removed library refresh status logs
- Removed podcast/radio search logs
- Removed audiobook browse debug dumps
- Removed favorites/library operation logs from screens
- Simplified error-only logging pattern

Note: Radio tab already has pull-to-refresh implemented.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:04:30 +00:00
Your NameandClaude Opus 4.5 5d8d7e4713 Add event-driven library sync via MA server events
Previously, library updates relied only on optimistic UI updates and
API response timing. This caused sync issues when MA operations timed
out (operations often succeed but take >30 seconds).

Now subscribing to MA server events for real-time library sync:
- media_item_added: Triggers library refresh when items added
- media_item_deleted: Triggers library refresh when items removed

Also added:
- Provider listener in artist details screen for reactive updates
- Debug logging for media events in API layer
- Event handlers that invalidate caches and refresh library data

This ensures the UI stays in sync even when API calls timeout,
since MA server sends events when operations complete.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 22:33:44 +00:00
Your NameandClaude Opus 4.5 5320ec631a Make library buttons update instantly with optimistic UI
Previously, library buttons waited for API response before updating.
This caused issues because:
1. MA operations can take >5 seconds and timeout
2. Button appeared stuck even when operation succeeded
3. No snackbar feedback until API completed

Now using optimistic updates:
- UI updates immediately when button is tapped
- Snackbar shows instantly
- API call runs in background
- Only revert on actual errors, not timeouts
  (timeouts usually mean MA is still processing successfully)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:48:53 +00:00
Your NameandClaude Opus 4.5 c7aea782ae Fix library button getting stuck after navigation
Two issues fixed:

1. "Not found in library" error handling:
   - When trying to remove an item that's already removed, MA returns
     "not found in library" error
   - Now we treat this as success (item is already not in library)
   - Button correctly updates to "add to library" state

2. Search cache invalidation:
   - Added invalidateSearchCache() method to CacheService
   - Called when items are added or removed from library
   - Ensures next search shows correct library status
   - Fixes issue where button state was wrong after navigating back

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:24:42 +00:00
Your NameandClaude Opus 4.5 08566d2b4d Fix Track constructor - remove invalid discNumber/trackNumber params
Track class doesn't have discNumber or trackNumber constructor parameters.
These properties are accessed through metadata, not as direct params.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:04:01 +00:00
Your NameandClaude Opus 4.5 177d9389c5 Add instant UI updates for favorites and library changes
Previously, toggling favorites or adding/removing from library required
a manual refresh to see changes. Now updates are instant:

Favorites:
- After toggle, local cache is updated with new favorite status
- notifyListeners() triggers immediate UI rebuild

Library:
- Remove: Item is removed from local cache instantly
- Add: Triggers background refresh (500ms delay) to fetch new item

This provides a responsive UX without requiring manual pull-to-refresh.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:52:34 +00:00
Your NameandClaude Opus 4.5 fc20309642 Fix library not showing all artists (only album artists)
The getArtists() API defaulted to albumArtistsOnly=true, which filtered
out artists that were added to library but don't have albums there.

Changed to albumArtistsOnly=false in:
- music_assistant_provider.dart loadArtists()
- library_provider.dart loadLibrary() and loadArtists()
- sync_service.dart background sync

Now artists like Ramones will appear in Library even if you only
added the artist (not their albums).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:46:01 +00:00
Your NameandClaude Opus 4.5 07cb1f7f95 Fix library button UI not updating after add/remove
The API calls were working (MA confirming with events), but the UI
wasn't updating because _isInLibrary() still checked the unchanged
item data.

Added local state tracking:
- _addedToLibrary and _removedFromLibrary Sets track changes in session
- _isInLibrary() checks local state first, then falls back to server data
- Add/remove handlers update tracking sets on success

Now buttons visually update immediately when library status changes.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:37:31 +00:00
Your NameandClaude Opus 4.5 69bf28882d Fix library add/remove buttons not working properly
The library add button was failing because when items already had a
library mapping, the code was falling back to the library provider
instead of using external providers like Spotify.

Changes:
- Fixed _addToLibrary in search_screen.dart to ALWAYS prefer non-library
  providers (ignoring availability flag which only affects playback)
- Applied same fix to artist_details_screen.dart, album_details_screen.dart,
  and podcast_detail_screen.dart
- Added proper handling for library-only items (show message instead of
  sending invalid request)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:20:31 +00:00
Your NameandClaude Opus 4.5 fba7091997 Fix: use playArtistRadio instead of playRadio for Artist type
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:53:35 +00:00
Your NameandClaude Opus 4.5 cdc6c5b11b Add library management and expandable actions for search results
- Fix URI bug: use providerDomain instead of providerInstance for library operations
- Add expandable dropdown menus for all search result types (long-press to reveal)
- Implement action buttons per media type:
  - Artist: Radio, Queue, Favorite, Library
  - Album: Play, Play On, Queue, Favorite, Library
  - Playlist: Play, Play On, Library
  - Audiobook: Play, Play On, Favorite, Library
  - Radio/Podcast: Play, Play On, Library
- Add library toggle button to Artist, Album, and Podcast detail screens
- Add API methods: playAlbum, addAlbumToQueue, addArtistToQueue, playPlaylist
- Add localization strings for playingAlbum and playingPlaylist

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:42:19 +00:00
Your NameandClaude Opus 4.5 3edb6af5a9 Add "Add to Library" button for search results
- Add music/library/add_item and music/library/remove_item API endpoints
- Add addToLibrary/removeFromLibrary methods in provider
- Add library_add icon button to search tiles (artist, album, playlist, audiobook, radio, podcast)
- Button only shows for items not already in library
- Add localization strings for library actions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:57:31 +00:00
Your NameandClaude Opus 4.5 cded364f1b Fix podcast, audiobook, and radio player issues
- Mini player buttons: Show play/skip 30s/power for podcasts/audiobooks when player list is expanded
- Fixed gap between expanded player and nav bar:
  - Use viewPadding.bottom to match BottomNavigationBar's calculation
  - Use Clip.hardEdge when expanded to avoid anti-alias edge artifacts
  - Add 2px overlap to eliminate any remaining subpixel rendering gaps
- Fixed stream quality overlapping chapter info in audiobook expanded player
- Added skip 10 seconds buttons (±10s) to expanded audiobook and podcast controls only
- Skip 10s and 30s buttons now use the same size for visual balance
- Removed verbose podcast name logging causing white flash behind mini player
- Fixed radio streams showing "Unknown Artist" - now extracts artist from stream metadata
- Added letter scrollbar to Podcasts and Radio library views

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:32:31 +00:00
Your Name 8f64a75206 Bump version to 2.8.0-beta+37 2026-01-04 13:53:48 +00:00
Your NameandClaude Opus 4.5 cbb67eacce Fix artist grid view hero animation
Add missing heroTag to ArtistAvatar in grid view - list view had it but grid view was missing it entirely, causing fade instead of hero animation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:29:41 +00:00
Your NameandClaude Opus 4.5 74f1b0f16f Persist podcast cover cache for smooth heroes + high-res
- Add SharedPreferences persistence for iTunes podcast cover URLs
- Load cached URLs on app start (before UI renders) for instant high-res
- Save new iTunes URLs as they're fetched for future launches
- Change iTunes artwork from 1400x1400 to 800x800 (still crisp, smaller payload)
- Remove useStableUrl workaround (no longer needed with persistence)

This ensures:
- Smooth hero animations (same URL in library and detail)
- High-res podcast covers (iTunes 800x800)
- Instant display on repeat launches (URLs loaded from storage)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:15:24 +00:00
Your NameandClaude Opus 4.5 9c293a6d64 Fix podcast hero: use stable URLs to prevent mid-session URL changes
ROOT CAUSE IDENTIFIED:
Albums/artists/audiobooks use getImageUrl() which ALWAYS returns the same URL.
Podcasts use getPodcastImageUrl() which can return DIFFERENT URLs:
- Before iTunes lookup: imageproxy URL
- After iTunes lookup: iTunes 1400px URL

This causes hero animation failures because:
1. Library displays imageproxy URL image
2. iTunes lookup completes asynchronously
3. User taps - getPodcastImageUrl now returns iTunes URL
4. Detail screen gets different URL than library is showing
5. Cache miss during hero animation = choppy

FIX:
- Added useStableUrl parameter to getPodcastImageUrl()
- When true, bypasses iTunes cache and always returns imageproxy URL
- Library now uses useStableUrl: true for all podcast images
- Precaching also uses useStableUrl: true
- This ensures SAME URL in library, precache, and detail screen

Also removed memCacheWidth from library (decode at native resolution).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:41:24 +00:00
Your NameandClaude Opus 4.5 4cba2ba889 Fix podcast hero: ensure all views have identical widget structure
Critical fix: List view had different structure from grid/detail:
- List: ClipRRect(8) → CachedNetworkImage (no Container wrapper)
- Grid: ClipRRect(16) → Container → CachedNetworkImage
- Detail: ClipRRect(16) → Container → CachedNetworkImage

Hero animations require IDENTICAL widget structures to morph smoothly.

Now all three match:
- borderRadius: 16 (was 8 in list view)
- Container wrapper with surfaceContainerHighest color
- CachedNetworkImage with fadeIn/fadeOut Duration.zero

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:37:40 +00:00
Your NameandClaude Opus 4.5 3b4d1add01 Fix podcast hero animations - match audiobook pattern exactly
ROOT CAUSE FOUND:
1. URL mismatch: getPodcastImageUrl() returns different URLs at different
   times (imageproxy vs iTunes) causing cache misses during hero animation
2. Widget structure mismatch: Library used ClipRRect but detail used
   BoxDecoration, causing hero to morph between incompatible widgets

FIXES APPLIED:

1. Use initialImageUrl FIRST (not as fallback):
   - Library passes exact URL it uses for display
   - Detail screen now uses that same URL for hero animation
   - Prevents iTunes cache from changing URL mid-flight

2. Match audiobook widget structure exactly:
   - Changed from: Container + BoxDecoration + DecorationImage
   - Changed to: ClipRRect + Container + CachedNetworkImage
   - Same structure as library = smooth hero morphing

3. Added proper CachedNetworkImage properties:
   - fadeInDuration: Duration.zero
   - fadeOutDuration: Duration.zero
   - placeholder and errorWidget handlers

Widget structure comparison (now matching):
  Library:  Hero → ClipRRect(16) → Container → CachedNetworkImage
  Detail:   Hero → ClipRRect(16) → Container → CachedNetworkImage

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:24:20 +00:00
Your NameandClaude Opus 4.5 5a0b3e4f61 Fix podcast hero animations and further improve search ranking
Hero Animation Fixes (matching album pattern exactly):
- Increase podcast detail cover size from 0.6/280px to 0.7/320px (matches albums)
- Use DecorationImage with CachedNetworkImageProvider instead of CachedNetworkImage
  widget (matches album detail screen exactly)
- Remove memCacheWidth/Height constraints for full resolution display
- Add box shadow to match album detail screen aesthetic
- Update border radius to 16px in both library grid and detail screen

Search Ranking Improvements:
- Calculate "prominence" of query in podcast name (query length / name length)
- Higher boost (+15) when query is >50% of podcast name
- Medium boost (+12) when query is 30-50% of name
- Base boost (+8) for other matches

Example: "Louis Theroux Interviews" with query "Louis Theroux"
- Query is 14/23 = 60% of name -> +15 boost
- Combined with word boundary match (60) = 75 points total
- Should rank above tracks with artist match (55 points max)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:05:23 +00:00
Your NameandClaude Opus 4.5 081afb3948 Fix podcast hero animations and search relevance
Hero Animation Fixes:
- Change getPodcastImageUrl default size from 1024px to 256px to match
  albums/artists and ensure cache consistency for smooth hero transitions
- Add podcast image pre-caching in library screen (matches album pattern)

Search Relevance Fixes:
- Fix _matchesWordBoundary() to handle multi-word queries like "Louis Theroux"
- Add podcast metadata checks for owner/creator fields (not just author/publisher)
- Increase podcast description bonus from +3 to +5 (matches other media types)
- Add fallback boost (+10) for podcasts when multi-word query matches in name
  but no creator metadata exists (podcast names often include host name)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:43:48 +00:00
Your NameandClaude Opus 4.5 3a668348a2 Improve hero animations and search relevance scoring
Hero Animation Fixes:
- Align memCacheWidth/Height to 256 across library, search, and detail screens
- Ensures same cached image is used for hero source and destination

Search Relevance Improvements:
- Add author/narrator scoring for Audiobooks (+15 exact, +8 contains)
- Add author/publisher/description scoring for Podcasts
- Podcasts matching creator name now rank higher (fixes Louis Theroux issue)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:20:45 +00:00
Your NameandClaude Opus 4.5 5e0277ad9f Fix type pills and hero animation improvements
- Add podcastSingular localization key
- Use singular labels in type pills (Podcast not Podcasts)
- Fix pill stretching for artist/radio/podcast with mainAxisSize.min
- Increase pill size (8x3 padding, 11px font)
- Align memCacheWidth/Height to 400 across library and detail screens
  for smoother hero animations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:02:49 +00:00
Your NameandClaude Opus 4.5 90805c522a Fix: use 'podcasts' localization key instead of 'podcast'
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:46:40 +00:00
Your NameandClaude Opus 4.5 1b2fedb544 Add podcasts to search results with colored type pills
- Add searchPodcasts API method and include podcasts in search results
- Add podcast tile with navigation to podcast detail screen
- Add colored type pills to distinguish media types in "All" view:
  - Track (blue), Album (purple), Artist (pink), Playlist (teal)
  - Audiobook (orange), Radio (red), Podcast (green)
- Fix choppy hero animations for podcast covers by adding memCacheWidth/memCacheHeight constraints

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:35:40 +00:00
Your NameandClaude Opus 4.5 7deb5b7fda Podcast improvements with iTunes high-res artwork
Episode list redesign:
- 72x72 episode covers (each can have unique artwork)
- Duration + release date on same line
- Long-press to expand: full description + action buttons
- Action buttons match album track style (44x44 icon-only)

Podcast cover quality fix:
- Fetch high-res artwork from iTunes API (1400x1400)
- Works for any podcast listed in iTunes (most podcasts)
- Correct podcast artwork, not episode covers
- Cached in background after podcast list loads

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:16:06 +00:00
Your Name 7cdf303116 Fix Track import - it's in media_item.dart not separate file 2026-01-04 09:44:46 +00:00
Your NameandClaude Opus 4.5 c898ca51a9 Smart podcast cover selection based on image source comparison
Compare podcast and episode image URLs to determine if they're the
same source image. If same source, use episode version for higher
quality. If different sources (unique episode covers), keep the
podcast's own cover to avoid incorrect artwork.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 09:44:03 +00:00
Your NameandClaude Opus 4.5 22b3526497 Only use episode cover as fallback when podcast has no cover
Previously we always overwrote podcast covers with episode covers,
which was wrong for podcasts where each episode has a unique cover.

Now we check if the podcast has its own image first and only fall
back to episode covers when the podcast itself has no cover image.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 09:41:58 +00:00
Your NameandClaude Opus 4.5 eea675ba53 Redesign podcast episode list with covers, dates, and expandable actions
- Replace play icon with actual episode covers (72x72, rounded)
- Each episode can have its own unique cover art
- Add release date display alongside episode duration
- Long-press to expand episode showing full description
- Expanded state reveals action buttons: Play, Add to Queue, Play On...
- Add debug logging for episode metadata to discover date field names

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 09:36:07 +00:00
Your NameandClaude Opus 4.5 e74f59e931 Use high-quality episode covers on podcast detail screen
Updated podcast_detail_screen.dart to use getPodcastImageUrl()
for both the cover display and color extraction.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:49:33 +00:00
Your NameandClaude Opus 4.5 8584353d65 Use episode covers for higher quality podcast library images
Music Assistant sometimes returns low-res images for podcasts. Added:
- Podcast cover cache populated with episode covers in background
- getPodcastImageUrl() method that checks cache first
- Library screen now uses getPodcastImageUrl for podcasts

Covers are loaded progressively after initial podcast list loads,
so the UI is not blocked while fetching episode data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:37:59 +00:00
Your NameandClaude Opus 4.5 0495e0c418 Fix podcast name detection for episodes with split titles
When podcast episode titles contain " - " (like "Show 73 - Mania for
Subjugation III"), the title was being split and "Show 73" became the
artist. The caching logic then kept the "existing" track thinking it
had better data, but this caused the album (podcast name) from the
player_updated event to be lost.

Fix: For podcast URIs, if the new track has album info but existing
doesn't, either prefer the new track or merge the album data into
the existing track. Also update _currentTrack when we have better
album data for podcasts.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:34:26 +00:00
Your NameandClaude Opus 4.5 87a0b8c04c Add podcast field extraction and detailed logging
- Log all currentMedia fields for podcast episodes
- Extract and store podcast field in metadata
- Add helper method for episode cover fallback
- Helps diagnose podcast name detection issues

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:13:13 +00:00
Your NameandClaude Opus 4.5 ef384fa41f Fix duplicate podcast name and add debug logging
- Hide album line for podcasts (was duplicating podcast name)
- Add debug logging to understand podcast data structure
- Helps diagnose why some podcasts show "Podcasts" fallback

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:07:51 +00:00
Your NameandClaude Opus 4.5 b2d39404b6 Fix localization: use podcasts instead of podcast
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:50:15 +00:00
Your NameandClaude Opus 4.5 c655f79b71 Add podcast skip controls and fix artist display in expanded player
- Add isPlayingPodcast getter to detect podcast episodes by URI pattern
- Add currentPodcastName getter to show podcast name instead of artist
- Use audiobook-style skip 30s controls for podcasts in expanded player
- Display podcast name in artist line instead of "Unknown Artist"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:41:11 +00:00
Your NameandClaude Opus 4.5 0bec855d7b Use correct endpoint: music/podcasts/podcast_episodes
Found the actual API endpoint from Music Assistant documentation.
Simplified the code to use the proper endpoint.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:19:09 +00:00
Your NameandClaude Opus 4.5 752dd9ac26 Try multiple approaches for podcast episodes like audiobooks
- Check metadata for episodes array
- Use provider_mappings to fetch from source provider (Spotify)
- Log total_episodes and metadata keys for debugging

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:15:57 +00:00
Your NameandClaude Opus 4.5 aa6d616741 Use podcast's actual URI for browsing episodes
- Use the podcast's uri from API response instead of constructed path
- Filter out folder navigation items (like "..")
- Add _parseBrowseEpisodes helper method
- Include duration parsing for episodes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:13:32 +00:00
Your NameandClaude Opus 4.5 8476798093 Fix MediaType parsing in podcast episode browse
- Parse media_type string to MediaType enum properly
- Default to MediaType.track if parsing fails

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:57:03 +00:00
Your NameandClaude Opus 4.5 a2327e5183 Add detailed logging for podcast episode parsing
- Log browse item structure (keys, name, label, uri, path, media_type)
- Parse browse items with fallback for name/label fields
- Add URI logging to play method for debugging
- Better handling of browse response format

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:47:39 +00:00
Your NameandClaude Opus 4.5 0fad37147f Fix podcast episodes and image resolution
Episodes:
- Add multiple fallback methods for fetching podcast episodes
- Try music/podcasts/get with various episode keys
- Try browse endpoint with podcast URI
- Try podcast_episodes library endpoint
- Add comprehensive debug logging

Images:
- Increase podcast image size from 512 to 1024
- Increase memory cache size for better quality

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:34:21 +00:00
Your NameandClaude Opus 4.5 3667df5b5c Fix podcast detail screen compilation errors
- Use CachedNetworkImageProvider for color extraction
- Change _formatDuration to accept Duration instead of int
- Replace non-existent podcastOff icon with microphoneOff
- Fix duration comparison to use Duration.zero

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:14:24 +00:00
Your NameandClaude Opus 4.5 4521318686 Add podcast details screen with episodes list
- Create PodcastDetailScreen with podcast cover, title, author
- Load and display episode list with play functionality
- Add hero animations between library and details screens
- Add expandable description section
- Add podcastCover hero tag constant
- Pass imageUrl parameter to navigation for adaptive colors

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:03:32 +00:00
Your NameandClaude Opus 4.5 29d14d9c13 Fix null safety for S.of(context) in podcast details
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:40:46 +00:00
Your NameandClaude Opus 4.5 250d79bd39 Fix AppLocalizations reference in podcast details
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:31:41 +00:00
Your NameandClaude Opus 4.5 c1837f5227 Add basic podcast support
- Add getPodcasts, getPodcastEpisodes, and playPodcastEpisode API methods
- Add podcast state (podcasts list, isLoadingPodcasts) to provider
- Replace "Coming soon" placeholder with functional podcasts tab
- Add list and grid view modes for podcasts (2-column and 3-column)
- Add view mode toggle support for podcasts
- Persist podcast view mode preference in settings
- Add localization strings for podcasts (noPodcasts, addPodcastsHint, episodes)
- Tapping a podcast shows placeholder message (details screen coming later)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:21:54 +00:00
Your NameandClaude Opus 4.5 a59946cc69 Fix radio stations layout and add grid view support
- Add proper spacing between radio station items in list view
- Add grid view support (2-column and 3-column layouts)
- Add view mode toggle button functionality for radio tab
- Persist radio view mode preference in settings
- Add pull-to-refresh support
- Optimize image caching based on view mode

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:15:04 +00:00
Your NameandClaude Opus 4.5 6e05a948f1 Add global radio station search via Music Assistant API
- Add searchRadioStations() method that searches with media_types=['radio']
- Combine library radio filtering with global search results
- Deduplicate results by name, preferring library items

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:52:55 +00:00
Your NameandClaude Opus 4.5 80a7848204 Filter radio stations from loaded list during search
The Music Assistant search API doesn't return radios, so filter
from the provider's pre-loaded radioStations list instead.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:31:26 +00:00
Your NameandClaude Opus 4.5 0d09fb3625 Add radio stations to search results
- Parse radios from search API response
- Add radio filter tab to search screen
- Add radio tile with play functionality
- Add playingRadioStation localization

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:14:35 +00:00
Your NameandClaude Opus 4.5 cff1edcc02 Optimize list scroll performance
- Add 200ms delay before fetching fallback images to avoid
  network requests during fast scrolling
- Use dynamic image cache sizes based on view mode:
  - List view: 128px (smaller thumbnails)
  - 3-col grid: 200px
  - 2-col grid: 300px
- Increase ListView cacheExtent from 500px to 1000px to
  pre-build more offscreen items

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:53:59 +00:00
Your NameandClaude Opus 4.5 300f92a327 Add fast settling physics for horizontal page swipes
Adds _FastPageScrollPhysics to both library and search screens
to reduce the time PageView animations take to settle. This makes
vertical scrolling within tabs more responsive after swiping
horizontally between pages.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:41:55 +00:00
Your NameandClaude Opus 4.5 c600029202 Use PageView.builder to only build visible tabs
Previously all 3-4 tabs were built upfront with PageView(children:).
Now only the current tab is built, with adjacent tabs for smooth swiping.

This should significantly improve tab swipe performance.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:01:09 +00:00
Your NameandClaude Opus 4.5 c703eef343 Fix RestorableInt/ValueListenable type mismatch
RestorableInt doesn't implement ValueListenable, so add separate
ValueNotifier for UI updates while keeping RestorableInt for state
restoration.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:43:45 +00:00
Your NameandClaude Opus 4.5 48ffe0c826 Debounce color extraction to avoid blocking UI during scroll
- Queue color extraction requests with 300ms debounce
- Process extractions in batch after scroll settles
- Add 50ms yield between extractions for UI responsiveness
- Add mounted checks to prevent work after disposal

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:33:34 +00:00
Your NameandClaude Opus 4.5 8986c697c3 perf: throttle letter scrollbar updates to reduce rebuilds
Only update scroll fraction when it changes by more than 1%, reducing
the number of setState calls and widget rebuilds during scrolling.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:29:19 +00:00
Your NameandClaude Opus 4.5 948599bf3e perf: pre-sort lists on load instead of every build
- Add cached sorted lists for playlists, tracks, audiobooks, authors, series
- Sort once when data is loaded, not on every widget rebuild
- Pre-group audiobooks by author when loaded
- Re-sort audiobooks when sort order changes (alpha/year)

This eliminates O(n log n) sorting operations during scroll/swipe animations.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:28:42 +00:00
Your NameandClaude Opus 4.5 11d3dec72d perf: eliminate full screen rebuilds during PageView swipe
- Remove setState from _onPageChanged in both library and search screens
- Use ValueNotifier + ValueListenableBuilder for filter chip updates
- Only filter chips rebuild during swipe, not entire screen
- Remove redundant _selectedCategoryIndex in library screen

This significantly reduces widget rebuilds during swipe animations.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:23:26 +00:00
Your NameandClaude Opus 4.5 d85407bee2 style: add Spotify-style fade gradient under filter bars
Content now fades out as it scrolls under the filter bar instead of
abruptly meeting it. Uses a 24px gradient from background color to
transparent on both library and search screens.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:55:53 +00:00
Your NameandClaude Opus 4.5 891c8948e0 fix: smooth letter scrollbar thumb motion
Use AnimatedPositioned with 50ms duration to smooth out the thumb
tracking during scroll, eliminating jerky/stuttery motion.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:30:55 +00:00
Your NameandClaude Opus 4.5 b7da11847a style: add visual gap under filter bars when content scrolls
Increased top padding on list/grid views in library and search screens
so there's a visible gap when content scrolls under the tab bars.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:25:47 +00:00
Your NameandClaude Opus 4.5 6eef5ff9dc fix: letter scrollbar thumb tracking and grab area
- Thumb now tracks scroll position instead of staying centered
- Added onDragStateChanged callback to prevent tab bar hiding during drag
- Made grab area wider (32px) for easier interaction
- Adjusted letter popup position for wider grab area

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:15:54 +00:00
Your Name 7405f0728e style: increase gap between library type and filter bars 2026-01-03 17:02:03 +00:00
Your NameandClaude Opus 4.5 317ec426be fix: filter tab vertical alignment and scroll-to-hide behavior
- Fix vertical alignment of text in filter tab row (center aligned)
- Only hide library type bar when scrolling, keep filter tab always visible
- Use Container with Alignment.center for proper text centering

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:01:37 +00:00
Your NameandClaude Opus 4.5 9902a9f0c5 feat: add letter scrollbar for fast list navigation
- Create LetterScrollbar widget that shows a letter popup when dragging
- Apply to library screen lists: Artists, Albums, Playlists, Authors, Audiobooks, Series
- Dragging the scroll thumb shows the current letter in a popup bubble
- Jump to items starting with that letter for quick navigation
- Closes #36

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:53:23 +00:00
Your NameandClaude Opus 4.5 5f6451153a refactor: improve filter bar spacing and add scroll-to-hide
Library screen:
- Reduce filter row height from 44px to 36px
- Add 8px spacing between library type and filter rows
- Remove bottom border line from filter bar
- Add scroll-to-hide behavior (like search screen)

Search screen:
- Reduce filter bar height from 44px to 36px
- Adjust padding to match smaller height

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:47:02 +00:00
Your NameandClaude Opus 4.5 fdefa32bef refactor: improve filter bar layout and consistency
Library screen:
- Justify media type chips across full width (spaceBetween)
- Reduce vertical spacing between rows (fixed 44px height)
- Removed redundant padding

Search screen:
- Filter bar now matches search bar width with rounded corners (12px)
- Content clips and scrolls within the rounded container
- Same 44px height as library screen for consistency

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:29:47 +00:00
Your NameandClaude Opus 4.5 00d1763c99 refactor: improve library filter layout per feedback
- Remove full-width background from media type row
- Show icons only when media type is selected (saves horizontal space)
- Use tertiaryContainer for selected state (complementary color)
- Move action buttons (favorites, view mode) inline with sub-category row
- Revert search screen to inline library toggle in search bar

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:08:40 +00:00
Your NameandClaude Opus 4.5 3e1c2f9da1 refactor: two-row filter layout with floating FABs
- Library screen: replace dropdown with two-row chip layout
  - Row 1: Media type chips (Music, Audiobooks, Podcasts, Radio) with secondaryContainer background and icons
  - Row 2: Sub-category chips (Artists, Albums, Playlists, etc.)
- Move favorites and view mode toggles to floating FABs in bottom-right above mini player
- Search screen: move library-only toggle from search bar to floating FAB
- Remove old dropdown-based media type selector

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 15:46:44 +00:00
Your NameandClaude Opus 4.5 2a583d28ef Fix build errors in library redesign
- Replace _tabController.index with _selectedCategoryIndex
- Fix metadata access to use Map syntax (metadata?['description'])
- Add playRadioStation method to API
- Use maProvider.api?.playRadioStation instead of undefined playMedia

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 14:57:30 +00:00
Your NameandClaude Opus 4.5 009af477ad Redesign library screen with dropdown + chips layout and add radio support
- Replace media type icon buttons with dropdown selector
- Replace TabBar with horizontal scrollable chips (matching search screen)
- Remove PlayerSelector from library AppBar (redundant with mini player)
- Add Radio as a new library media type
- Add getRadioStations API endpoint
- Add loadRadioStations to provider
- Add radio stations tab with list view
- Add localization strings for radio, stations, selectLibrary

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 14:45:05 +00:00
Your Name 91def41c9f Raise secondary line in 3-line mini player by 1px 2026-01-02 09:45:23 +00:00
Your Name ede34224f4 Fix 3-line mini player: restore 16px font, raise primary line 2026-01-02 09:26:11 +00:00
Your Name 676848779f Center power button vertically 2026-01-02 09:20:21 +00:00
Your NameandClaude Opus 4.5 c7dd415169 Fix power button style, reduce album gap, adjust mini player spacing
- Power button now matches PlayerCard style (IconButton, no background)
- Album details: reduce bottom padding from 24px to 12px
- Mini player: raise secondary line from y=42 to y=40

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 09:06:52 +00:00
Your NameandClaude Opus 4.5 b0baaecb1f Add power button and improve mini player 2-line layout
- Add power toggle button on right side (2-line off state only)
- Increase primary font size from 16px to 18px in 2-line mode
- Evenly space the two text lines vertically (14px and 42px)
- Power button shows powered state with opacity change

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:50:10 +00:00
Your NameandClaude Opus 4.5 30a4a432f1 Fix row height clipping and increase detail screen artwork
- Add 8px total to row height formula for 4th row behind mini player
- Add clipBehavior: Clip.none to album row for descender overflow
- Add 4px bottom padding to album cards for descender room (y, g, j, q)
- Tighten artist card line height to 1.15 for 2-line names
- Increase detail screen artwork from 40-50% to 70% on all screens

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:48:11 +00:00
Your NameandClaude Opus 4.5 eedf846b36 Bump version to 2.7.4-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 23:19:33 +00:00
Your NameandClaude Opus 4.5 a2788d6815 Improve accessibility and touch targets
Accessibility:
- Add Semantics to player control buttons (previous, play/pause, next)
- Include semantic labels and hints for screen readers
- Play/pause button includes "long press to stop" hint

Touch Targets:
- Increase 28dp touch targets to 44dp in player_card.dart
- Increase 28dp touch targets to 44dp in expandable_player.dart
- Comments note icons remain 28dp for visual balance

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:58:12 +00:00
Your NameandClaude Opus 4.5 7cbe8cf15b Fix memory leaks, race conditions, and stability issues
Memory Management:
- Add LRU eviction to all caches with configurable max sizes
- Fix static memory leak in Player model with proper LRU cleanup
- Add PCM buffer overflow protection (max 500 chunks / ~10s)

Database Performance:
- Add indexes for RecentlyPlayed (profile, played_at)
- Add indexes for LibraryCache (itemType, isDeleted)
- Add indexes for CachedQueue (playerId, position)
- Bump schema version to 5 with migration

Race Conditions:
- Add connection deduplication guard in SendspinService
- Complete pending API requests on disconnect instead of abandoning
- Add position tracker anchor timeout (30s max drift)

Error Handling:
- Add catchError to fire-and-forget pause/skip commands
- Log unawaited errors instead of silently dropping them

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:55:59 +00:00
Your NameandClaude Opus 4.5 79e3862895 Fix critical audit issues: secure storage, memory leaks, race conditions
Security:
- Add flutter_secure_storage dependency for credential protection
- Create SecureStorageService for encrypted credential storage
- Migrate auth tokens, passwords, API tokens from SharedPreferences
- Add automatic migration for existing users in main.dart

Memory Leaks:
- Fix audio session listener disposal in MassivAudioHandler
- Store all 4 stream subscriptions and add dispose() method
- Store connection state subscription in MusicAssistantProvider
- Fix StreamController disposal in HardwareVolumeService

Race Conditions:
- Add reentrancy guard to selectPlayer() to prevent concurrent calls

Code Quality:
- Fix 7 silent exception handlers across codebase
- Log errors with context instead of swallowing silently
- Fix double opacity bug in player_controls.dart shuffle/repeat buttons

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:28:49 +00:00
Your NameandClaude Opus 4.5 02d5dac065 Add comprehensive codebase audit report
Complete audit covering 6 areas:
- Security: 6 CRITICAL issues (plaintext credentials, cert validation)
- Memory: 6 leak locations (unbounded caches, untracked subscriptions)
- Race Conditions: 4 issues (player state, async reentrancy)
- Architecture: God class (4,469 LOC), zero tests, 55 hardcoded colors
- Accessibility: 0 Semantics widgets, touch targets below 48dp
- Audio/Networking: 6 HIGH issues (no indexes, non-atomic queue)

Overall score: 5.5/10
Estimated technical debt: 44-66 dev-days

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:16:36 +00:00
Your NameandClaude Opus 4.5 f000806e18 Add volume swipe hint and shorten sync hint
Device selector now shows two hints:
- "Long-press to sync" (shortened from "Tap and hold a player to sync")
- "Swipe to adjust volume" (new)

Updated all three language files (en, de, es).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:24:46 +00:00
Your NameandClaude Opus 4.5 7c18eb1b71 Fix sync: translate both target AND leader to Sendspin IDs
Cast players (chromecast provider) don't support group commands - only
their Sendspin counterparts do. The previous fix incorrectly tried to
use Cast UUIDs as leaders, which caused "Player X does not support
group commands" errors.

Now we translate BOTH the target AND leader to Sendspin IDs when syncing,
since only Sendspin players support the SET_MEMBERS feature required for
grouping.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:55:57 +00:00
Your NameandClaude Opus 4.5 23292e796c Remove verbose isPlayerManuallySynced debug logging
This debug log was firing on every UI build cycle for every player card,
creating excessive noise in the logs.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:37:41 +00:00
Your NameandClaude Opus 4.5 c0e74c0539 Fix unsync for pure Sendspin players that don't support set_members
Pure Sendspin CLI players (like sendspin-cli-dining-room-hifi) don't
implement the SET_MEMBERS feature required for ungrouping. When unsyncing
such a player fails with "set_members needs to be implemented", fall back
to unsyncing via the leader instead. This dissolves the entire group but
at least provides a working path.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:36:34 +00:00
Your NameandClaude Opus 4.5 c965ed2c78 Workaround: Use Cast UUID as leader when syncing players
When syncing a player to the leader, use the Cast player UUID instead of
the Sendspin ID. This may help prevent MA from switching to the Sendspin
player's queue (which can have stale content like old audiobooks).

The root cause is a Music Assistant server bug where the Sendspin player
has a separate queue from the Cast player, and syncing with the Sendspin
ID as leader causes MA to start streaming from the Sendspin queue.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:25:32 +00:00
Your NameandClaude Opus 4.5 c060bcffdf Fix unsync removing leader instead of child from sync group
When long-pressing a child player to unsync it, the code was incorrectly
calling unsync on the leader instead, which caused the leader to leave
the group while the child remained synced to another player.

The flawed logic tried to "unsync via leader first" for child players,
but calling ungroup on a leader dissolves the entire group rather than
removing a specific child.

Now unsyncs the child directly, which correctly removes just that player
from the sync group.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:07:15 +00:00
Your NameandClaude Opus 4.5 8604e5ee12 Remove Tracks heading and standardize Play On menus
- Remove redundant "Tracks" heading from album detail view
- Update album_details_screen to use shared showPlayerPickerSheet
- Update audiobook_detail_screen to use shared showPlayerPickerSheet
- All Play On menus now have status indicators, drag handle, and theming

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:32:43 +00:00
Your NameandClaude Opus 4.5 d237e07d95 Fix mini player layout for 2-line vs 3-line states
- Add centered 2-line layout for non-playing state (player off)
- Add tertiaryText parameter to MiniPlayerContent for 3-line support
- Peek content now shows 3 lines when player has track, 2 centered when not
- DeviceSelectorBar uses centered 2-line layout (no empty third line)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 18:55:33 +00:00
Your NameandClaude Opus 4.5 b91789a6f0 Clarify directional volume swipe behavior (Option 4)
Volume swipe already uses incremental deltas - drag right increases,
drag left decreases. Multiple swipes accumulate as each starts from
current volume. Removed unused _dragStartX variable and added comments
to clarify this behavior.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 18:21:22 +00:00
Your NameandClaude Opus 4.5 28fc83613e Fix mini player volume overlay z-order
Move volume overlay to end of Stack children so it renders on top of
all content (album art, track info, buttons) when adjusting volume.
Matches PlayerCard behavior where overlay completely covers the card.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 18:08:51 +00:00
Your NameandClaude Opus 4.5 12d0d90193 Throttle volume API calls to prevent flooding
- Only send volume updates every 150ms during drag
- Send final volume value on release
- Visual feedback still updates immediately

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 17:46:52 +00:00
Your NameandClaude Opus 4.5 1980fbb37f Add volume swipe to mini player when device list is expanded
When device reveal overlay is visible:
- Mini player horizontal swipe = volume control (two-tone overlay)
- Live volume changes as you drag

When device reveal is closed:
- Mini player horizontal swipe = change player (existing behavior)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 17:36:59 +00:00
Your NameandClaude Opus 4.5 406379e3e5 Refine mini player 3-line layout and add volume swipe to player cards
Mini player refinements:
- Reduce height from 76px to 72px
- Adjust line spacing (secondary at 27px, tertiary at 46px)
- Remove player name swap on primary line when reveal visible
  (no longer needed since player name is now on line 3)

Volume swipe on PlayerCards:
- Horizontal swipe adjusts volume with live feedback
- Two-tone overlay covers card while dragging (same colors as progress bar)
- Volume changes apply immediately as you drag

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 17:21:18 +00:00
Your NameandClaude Opus 4.5 7bb514ac74 Fix const error: BottomSpacing.withMiniPlayer is now a getter
Remove const from SliverToBoxAdapter that uses the non-const getter.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:59:10 +00:00
Your NameandClaude Opus 4.5 ab0f037ecf Add 3-line mini player: track, artist, player name
- Increase mini player height from 64px to 76px
- Increase album art to 76x76 to fill vertical space
- Add player name as third line (same size as artist, same color as track)
- Refactor BottomSpacing to reference MiniPlayerLayout.height
- Refactor _collapsedHeight and _collapsedArtSize to reference MiniPlayerLayout

Single source of truth for mini player dimensions now.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:46:54 +00:00
Your NameandClaude Opus 4.5 75167b58a9 Merge feature/player-name-pill: Sendspin sync fixes
Key changes:
- Cast-to-Sendspin ID mapping and persistence
- Sync fixes for unavailable/mixed player types
- Manual sync yellow border improvements
- Player name pill removed (didn't work out)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:13:37 +00:00
Your NameandClaude Opus 4.5 28bd1ad125 Remove player name pill from mini player
The pill experiment didn't work out - removing it entirely.
Keeps all the Sendspin sync fixes.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:12:34 +00:00
Your NameandClaude Opus 4.5 cea6dd46e3 Persist Cast-to-Sendspin mappings in database
When we discover a Cast+Sendspin player pair, save the mapping to
the database. On startup, load persisted mappings into memory.
This allows syncing to work even when the Sendspin player is
unavailable (device off) - we remember that it has Sendspin enabled.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 12:06:57 +00:00
Your NameandClaude Opus 4.5 3935312982 Fix sync for unavailable Sendspin players
When a Cast device with Sendspin is powered off, its Sendspin player
becomes unavailable and won't be in _castToSendspinIdMap. Now we also
check if the computed Sendspin ID exists in any sync group's members
(like "All Speakers") to identify players with Sendspin enabled.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:58:24 +00:00
Your NameandClaude Opus 4.5 634d5ddac2 Fix sync for Cast players without Sendspin
Only translate Cast IDs to Sendspin IDs when the player is
actually in _castToSendspinIdMap. Cast players without Sendspin
enabled should use their original UUID for group commands.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:43:08 +00:00
Your NameandClaude Opus 4.5 26282a1945 Fix unsync for child players that don't support set_members
When unsyncing a child player (like Sendspin CLI), the direct unsync call
fails with "set_members needs to be implemented" because the player doesn't
support that feature.

Fix: When unsyncing a child player, first try to unsync via the leader
(which does support set_members). This translates the syncedTo Cast UUID
to the Sendspin leader ID and calls unsync on that instead.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:37:59 +00:00
Your NameandClaude Opus 4.5 111c74ce1a Fix queue fetch for Sendspin players - translate ID to Cast UUID
When fetching the queue for a Sendspin player (e.g., cast-7df484e3),
we need to translate it to the corresponding Cast UUID (e.g.,
7df484e3-d2ee-c897-f746-2dffc29595ff) because MA stores queues under
the Cast UUID, not the Sendspin ID.

This was causing the mini player to show "off state" for Shield after
syncing another player to it - the queue fetch was returning empty
because we were querying with the wrong player ID.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:23:45 +00:00
Your NameandClaude Opus 4.5 39c462d624 Fix Sendspin player track cache lookup for mini player
When a Sendspin player (e.g., Shield) is selected and synced to by another
player, the mini player was showing "off state" because track cache lookups
were failing. The issue was that track events come from MA with Cast UUIDs
but the selected player uses Sendspin IDs (cast-xxxxxxxx format).

Changes:
- Add getAllCachedPlayerIds() to cache service for dynamic ID lookup
- Add dual-caching: when caching a track for a Cast player, also cache
  under its computed Sendspin ID (cast-{first 8 chars of UUID})
- This applies to _handlePlayerUpdatedEvent, _preloadPlayerTrack, and
  _updatePlayerState where tracks are cached
- Add dynamic Sendspin ID computation when looking up tracks, trying
  both the map and computed IDs as fallbacks

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:01:26 +00:00
Your NameandClaude Opus 4.5 fd4d264850 Adjust pill: move right 1px, use dark tertiary color
- Changed right: 1 to right: 0 (moves pill right 1px)
- Use tertiaryContainer for background (darker complementary color)
- Use onTertiaryContainer for text color

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 10:45:46 +00:00
Your NameandClaude Opus 4.5 26accb81e8 Fix Sendspin player state and name in mini player
1. Track cache lookup now checks translated Cast<->Sendspin IDs
2. Player state updates preserve the renamed name (without "(Sendspin)")
   by using processed player from _availablePlayers
3. Player update events now trigger state refresh for translated IDs
   (events may come with Cast ID but selected player is Sendspin)

This fixes:
- Mini player showing "Shield (Sendspin)" instead of "Shield"
- Mini player showing off state when Shield is selected and synced
- Track info not loading for Sendspin players

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 10:38:51 +00:00
Your NameandClaude Opus 4.5 0e8ab387e2 Fix sync UI for Cast+Sendspin players
1. Yellow border now shows on synced children even when leader is
   Sendspin version (translated ID lookup in isPlayerManuallySynced)
2. Selected player no longer jumps after sync when Cast player gets
   replaced by Sendspin version (ID translation in player selection)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 10:14:32 +00:00
Your NameandClaude Opus 4.5 3073080c77 Make player name pill match mini player colors
Use primaryContainer/onPrimaryContainer instead of tertiary colors
for a more subtle, unified appearance with the mini player.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 10:08:20 +00:00
Your NameandClaude Opus 4.5 a63bd5a3e0 Fix sync TO Cast players by computing Sendspin ID dynamically
The previous fix only worked when the Sendspin player was already
visible in the player list. But Cast players need their Sendspin
counterpart for group commands, even before Sendspin playback starts.

This fix:
- Computes Sendspin ID dynamically for chromecast players: cast-{first 8 chars of UUID}
- No longer relies on _castToSendspinIdMap having a pre-built mapping
- Works for both Kitchen speaker and Shield (any Cast+Sendspin device)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:56:54 +00:00
Your NameandClaude Opus 4.5 aa366a0910 Fix Cast+Sendspin sync for devices with raw ID naming
Some Cast devices (like Shield) have their Sendspin player registered
with a raw ID as the name (e.g., "cast-7df484e3") instead of the
standard "DeviceName (Sendspin)" format.

This fix:
- Detects Sendspin players by ID pattern (cast-{uuid-prefix})
- Maps Cast player IDs to Sendspin IDs for these raw-named players
- Enables sync TO these Cast devices by translating the leader ID

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:47:49 +00:00
Your NameandClaude Opus 4.5 d8edf19699 Refine player name pill positioning and border radius
- Move pill 1px left to hide sharp corner behind mini player curve
- Match pill border radius to mini player (16px) for seamless look
- Increase left padding to accommodate larger radius
- Raise text up (top padding 5→3) for better vertical balance

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:22:19 +00:00
Your NameandClaude Opus 4.5 3d9e4b9a93 Fix sync to Cast+Sendspin players by translating leader ID
When syncing to a Chromecast player that has Sendspin enabled,
the leader player ID also needs to be translated from the Cast ID
to the Sendspin ID. Previously only the target was translated.

This fixes "Player Shield does not support group commands" error
when trying to sync other players to a Cast+Sendspin player.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:16:50 +00:00
Your NameandClaude Opus 4.5 0c1995d918 Fix sync to Cast+Sendspin players by translating leader ID
When syncing to a Chromecast player that has Sendspin enabled,
the leader player ID also needs to be translated from the Cast ID
to the Sendspin ID. Previously only the target was translated.

This fixes "Player Shield does not support group commands" error
when trying to sync other players to a Cast+Sendspin player.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:10:16 +00:00
Your NameandClaude Opus 4.5 e1fb75b868 Player name pill with teardrop tail effect behind mini player
- Pill renders BEHIND mini player (first in Stack)
- Tertiary color for contrast
- Extends down behind player with bottom padding
- Mini player's curved corner reveals the "tail" naturally tapering
- Top corners rounded, bottom straight for seamless extension

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 08:50:08 +00:00
Your NameandClaude Opus 4.5 f56b4e6b24 Integrate player name pill as seamless tab with mini player
- Top right position, flush with right edge
- Same background color as mini player (collapsedBgUnplayed)
- Progress bar flows through the pill matching main player
- Only top-left corner rounded, right side matches player border radius
- Creates seamless integrated appearance

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 00:21:52 +00:00
Your NameandClaude Opus 4.5 5146035a6d Move player name pill to top left of mini player
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 23:43:12 +00:00
Your NameandClaude Opus 4.5 4aca263bf2 Move player name pill to top right of mini player
- Position at top right instead of centered below
- Right edge flush with mini player edge
- Slightly larger (font 12, padding 12x5)
- Reduced border radius (8) for smoother transition

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 23:26:09 +00:00
Your NameandClaude Opus 4.5 8725a4a0b2 Fix version code by using manual build number (fixes #34)
- Reverted workflow to not use --build-number flag
- Added +35 build number to pubspec.yaml (higher than previous 31)
- Build numbers should be manually incremented in pubspec.yaml for each release

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 19:30:47 +00:00
Your NameandClaude Opus 4.5 b144ff48fb Fix version code always being 1 (fixes #34)
Use GitHub run_number as the build number to ensure each build has an
incrementing versionCode. This fixes the "newer version already installed"
error when upgrading.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 19:25:31 +00:00
Your NameandClaude Opus 4.5 524289924b Move player name pill to center position
Horizontally centered below the mini player instead of bottom-right.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 19:12:29 +00:00
Your NameandClaude Opus 4.5 6bf358cc0e Fix pill font to use Roboto explicitly
The player name pill was inheriting some weird text style from parent widgets.
Added explicit fontFamily: 'Roboto' to match the app's design.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 18:55:38 +00:00
Your NameandClaude Opus 4.5 e269132b22 fix: Move pill outside Material to prevent clipping
Wrap Container in outer Stack with Clip.none, place pill in outer Stack
so it's not clipped by Material's Clip.antiAlias

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 18:30:58 +00:00
Your NameandClaude Opus 4.5 7453aa548a fix: Move pill inside inner Stack for correct z-order, fix font styling
- Move pill to end of Stack children so it renders on top
- Add explicit TextStyle properties to prevent inherited styling:
  - decoration: TextDecoration.none (removes underline)
  - fontFamily: null (uses default font, not monospace)
  - letterSpacing: 0
- Slightly larger: 11px font, 12px radius, more padding

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 17:16:09 +00:00
Your NameandClaude Opus 4.5 073ab14d2b feat: Add player name pill attached to bottom-right of mini player
- Pill shows selected player name below the mini player
- Uses adaptive tertiary color for complementary look
- Fades out smoothly during player expansion
- Only visible when track is playing (DeviceSelectorBar already shows player name when idle)
- Positioned at bottom: -10px, right: 8px to attach beneath mini player

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 16:59:44 +00:00
Your NameandClaude Opus 4.5 22fc4dd42a fix: Distinguish pre-configured groups from manual sync by checking if leader ID is in groupMembers
Pre-configured group players (like "All Speakers") have groupMembers that
list the child players, but the group's own ID is NOT in that list.
Manual sync groups have the leader's ID IN the groupMembers list.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:06:21 +00:00
Your NameandClaude Opus 4.5 fd7bcc67cf debug: Use DebugLogger for yellow border diagnostics
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:03:57 +00:00
Your NameandClaude Opus 4.5 3ffdb071b6 debug: Add logging to diagnose group player yellow border issue
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 10:37:39 +00:00
Your NameandClaude Opus 4.5 cce8cf6f63 fix: Group players never show yellow border + tap to dismiss when player off
- Move player_group check to top of isPlayerManuallySynced() to prevent
  edge cases where group players could incorrectly show yellow border
- Add onTap handler to _buildDeviceSelectorBar to dismiss player reveal
  when tapping selected player that has no track (matching morphing player)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 10:09:11 +00:00
Your NameandClaude Opus 4.5 bb2f33229e fix: Yellow border shows for both leader and children in manual sync
Updated isPlayerManuallySynced to detect:
- Case 1: Player is a child synced to a regular player (not a group)
- Case 2: Player is a leader with groupMembers (but not a player_group provider)

This ensures both players in a manual sync show the yellow border,
while pre-configured groups like "All Speakers" show no border.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 09:32:22 +00:00
Your NameandClaude Opus 4.5 ed90459049 fix: Use isPlayerManuallySynced for player reveal overlay cards
The second PlayerCard in player_reveal_overlay.dart was using
player.isGrouped directly instead of the provider's isPlayerManuallySynced
method. This caused group players like "All Speakers" to incorrectly show
the yellow synced border. Now both PlayerCard instances use the same
correct logic that only highlights manually synced players.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 09:08:57 +00:00
Your NameandClaude Opus 4.5 77531e6d62 fix: Yellow border only for manually synced players, not MA groups
- Add isPlayerManuallySynced() helper in provider to check if syncedTo target is a regular player
- Pre-configured MA speaker groups (provider='player_group') no longer show yellow border
- Fix PlayerCard border clipping by using Stack with border overlay on top
- Update all widgets to use provider method for sync detection

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 06:13:09 +00:00
Your NameandClaude Opus 4.5 8122242045 fix: Improve player list UX and synced player highlighting
- Tapping active player when list expanded now collapses the list
- Scribe player shows Play/Next/Power buttons when list visible (matching other players)
- Yellow synced border renders on top using foregroundDecoration (fixes corner clipping)
- Yellow border only shows for manually synced players, not pre-configured MA groups

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 05:54:43 +00:00
Your NameandClaude Opus 4.5 7f871f0078 chore: release v2.7.3-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 09:40:57 +00:00
Your NameandClaude Opus 4.5 573a5f6fe7 chore: bump to 2.7.3-beta+36 with shuffle queue fix
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 07:35:44 +00:00
Your NameandClaude Opus 4.5 cb87492c9c fix: shuffle toggle now correctly updates queue display
Root cause: After toggling shuffle, the queue comparison only checked
metadata (length, currentIndex, shuffle state). When the optimistic
update set shuffleEnabled=true with old items, and the server returned
shuffleEnabled=true with reordered items, the comparison showed "no change"
because both had the same shuffle state.

Changes:
- Add item reordering detection by comparing first 3 queue item IDs
- Add 150ms delay after toggle API call to ensure server finishes reordering
- Add debug logging to track reordering detection

Fixes queue display not updating when shuffle is toggled on/off.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 07:35:24 +00:00
Your Name b1b91e8dca chore: bump to 2.7.3-beta+35 with shuffle button visual fix 2025-12-30 07:14:38 +00:00
Your NameandClaude Opus 4.5 ba0f21856a fix: shuffle/repeat button visual state now updates immediately
Apply optimistic UI updates for shuffle and repeat toggles:
- Immediately update local queue state before API call
- Prevents cached queue from overwriting optimistic state
- Ensures button color changes instantly on tap

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:57:04 +00:00
Your Name 218bec9607 debug: add more shuffle state logging 2025-12-30 06:27:50 +00:00
Your Name 2d491e1ae9 chore: bump to 2.7.3-beta+33 with shuffle logging 2025-12-30 06:01:07 +00:00
Your Name 1ab51bbf25 debug: add shuffle toggle logging for diagnostics 2025-12-30 05:57:56 +00:00
Your Name 5e6da3ec2f chore: bump version to 2.7.3-beta+32 2025-12-30 05:56:32 +00:00
Your NameandClaude Opus 4.5 3894574a05 fix(#28): add shuffle_enabled parameter to toggle shuffle API
fix(#21): prevent early return on coldStart for auto-selection priority

The shuffle button was failing because the Music Assistant API requires
the shuffle_enabled boolean parameter. Added the parameter through all
layers (API → provider → widget).

The auto-selection wasn't applying Priority 2 (local player) when
"Prefer Local Player" was disabled because an early cache-valid return
was bypassing the cold start priority logic.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 05:37:30 +00:00
Your NameandClaude Opus 4.5 7fe4e553ee feat: add app_id logging for external source diagnosis
Expose app_id on Player model and log it for playing players
to help diagnose why Spotify Connect isn't detected as external source.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 05:26:27 +00:00
Your NameandClaude Opus 4.5 459eb316b2 fix: correct method name clearRemotePlaybackState
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 23:25:30 +00:00
Your NameandClaude Opus 4.5 d3d03fd4d1 fix(#27): clear stale track data when external source takes over
When a player is using an external source (Spotify Connect, TV optical,
etc.), Ensemble was showing stale MA track data instead of clearing it.

Changes:
- selectPlayer(): Clear _currentTrack for external source players
- selectPlayer(): Skip position tracker updates for external sources
- _preloadPlayerTrack(): Skip preloading for external source players
- _updatePlayerState(): Early return for external sources, clearing
  track, audiobook context, and notification
- player_selector.dart: Exclude external sources from badge count
- Auto-selection logic: Exclude external sources from "playing player"
  priority in both music_assistant_provider and player_provider

The isExternalSource flag (detecting app_id != 'music_assistant') was
already being set correctly in the Player model, but wasn't being used
throughout the app to prevent showing stale data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 23:16:35 +00:00
Your NameandClaude Opus 4.5 8f5008b902 fix: responsive cover sizing on detail screens
Use percentage-based sizing (40-50% of screen width) with min/max
clamps to adapt covers to different screen sizes (phones vs tablets).

Updated screens:
- album_details_screen: 50% width, 200-320px
- audiobook_detail_screen: 50% width, 200-320px
- audiobook_series_screen: 50% width, 200-320px
- artist_details_screen: 40% width, 160-280px (circular)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 22:32:41 +00:00
Your NameandClaude Opus 4.5 c3acd0029e fix: correct nullable return type in device ID service
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 21:36:55 +00:00
Your NameandClaude Opus 4.5 925feb89b6 fix: use stable device ID to prevent stale players
- Replace random UUID with ANDROID_ID (survives storage wipes)
- Include MA username in player ID for multi-device support
- Auto-detect Phone vs Tablet based on screen size (600dp threshold)
- Format: ensemble_{username}_{deviceId8}
- Prevents duplicate players when clearing app storage

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 21:26:29 +00:00
Your Name 29758eb9bb chore: bump version to 2.7.2-beta 2025-12-29 18:28:52 +00:00
Your NameandClaude Opus 4.5 332de9e00c perf: search screen audit fixes
- Only rebuild when clear button visibility changes (not every keystroke)
- Use context.select for connection status to avoid unnecessary rebuilds
- Cache available filters list to avoid recalculating per build
- Use theme-aware colors for filter chips (light/dark mode support)
- Remove dead code (_buildListItems method - 88 lines)
- Use safe .whereType<T>() instead of unsafe .cast<T>()

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:05:49 +00:00
Your Name 6f503c8540 fix: add gap between search bar and filter bar, optimize scroll to avoid animation jank 2025-12-29 17:41:45 +00:00
Your Name 9950ff2063 fix: remove container wrapper and extra padding from filter bar 2025-12-29 17:25:49 +00:00
Your Name 4c777a93e1 fix: add solid background to filter bar container 2025-12-29 17:15:54 +00:00
Your Name a96578bbf0 fix: remove gap between filter bar and results 2025-12-29 16:54:16 +00:00
Your Name 8fa95705e1 fix: keep filter bar visible when scrolling 2025-12-29 16:34:17 +00:00
Your NameandClaude Opus 4.5 9dcca2ce54 fix: improve search filter bar scroll and layout behavior
- Filter bar only scrolls when active filter would be off-screen
- Changed from Stack overlay to Column layout
- Results no longer scroll behind filter bar
- Filter bar hides on scroll along with search bar

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:17:02 +00:00
Your NameandClaude Opus 4.5 4152c3d67f perf(#23): use ListView.builder for player list with many players
- Only builds visible player cards (lazy loading)
- Uses itemExtent for optimal scroll performance
- Reduces animation work from O(n) to O(visible) per frame
- Should fix choppy animation reported by shpongledsummer

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 14:42:10 +00:00
Your NameandClaude Opus 4.5 d84fb58a5c fix: only allow swipe down for dismiss, ignore swipe up
Swipe up was incorrectly moving cards down because delta negation
was inverting the direction. Now:
- Swipe up (finger up): ignored when not scrollable
- Swipe down (finger down): triggers dismiss gesture

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:50:57 +00:00
Your NameandClaude Opus 4.5 128d417014 feat(#23): smart scroll for player list overflow
- Add scroll when player list exceeds 80% of available screen height
- Swipe down dismisses only when scrolled to top
- When scrolled down, swipe gestures scroll the list instead
- No behavior change for small player counts (5-6 players)

This matches the common pattern used by Instagram, Apple Maps, etc.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:35:49 +00:00
Your NameandClaude Opus 4.5 176fa5c8b9 fix: revert player list scrollability changes
The scrollability changes were interfering with the drag-to-dismiss
gesture. For normal player counts (5-6), scrolling isn't needed.
Issue #23 (many players overflow) needs a different approach.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:04:38 +00:00
Your NameandClaude Opus 4.5 ccaf764f52 Add missing player_picker_sheet.dart from feature branch
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 12:45:37 +00:00
Your NameandClaude Opus 4.5 ccda231fb2 Fix GitHub issues #27, #23, add hero animations to search
## Issue Fixes

### #27 - DLNA external source detection
- Add app_id check as primary indicator for external sources
- When app_id != 'music_assistant', mark as external source
- Keeps existing URI-based detection as fallback

### #23 - Speaker selection scrollability
- Add top constraint to player overlay for max height
- Wrap player list in SingleChildScrollView with Flexible
- Enable scrolling when more than 5 players (BouncingScrollPhysics)
- Prevents player cards from overflowing on devices with many speakers

### #21 - Auto-selection debugging
- Add detailed logging when Priority 2 (local player) fails
- Log whether local player is missing from list or not available
- Log when builtinPlayerId is null

## Enhancements

### Hero animations for search results
- Add hero animations to album, artist, and audiobook tiles
- Use FadeSlidePageRoute for smooth transitions
- Use 'search' suffix for hero tags to avoid conflicts
- Update adaptive colors on navigation tap

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 12:34:13 +00:00
Your NameandClaude Opus 4.5 0f82829172 Fix action button consistency on artist details screen
- Radio button now shows player selection popup (consistent with search/album)
- Add to Queue button now shows player selection popup (was broken - did nothing without selected player)
- Removed redundant "Play On" button (Radio now provides the same functionality)
- Renamed _showPlayOnMenu to _showRadioMenu for clarity
- Removed unused _startArtistRadio method

Artist details header now has: [Radio] [Add to Queue] [Favorite]
All buttons work consistently with popup selection, matching search/album behavior.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 11:53:16 +00:00
Your NameandClaude Opus 4.5 0ccde33c85 Standardize bottom sheet implementations across all screens
- All sheets now use consistent pattern: transparent background with
  Container decoration, mainAxisSize.min, Flexible with shrinkWrap
- All sheets hide mini player before showing and restore on dismiss
- All sheets include safe area bottom padding
- Fixed album_details_screen _showPlayOnSheet (was using fixed 45% height)
- Fixed artist_details_screen _showPlayOnMenu (was missing hidePlayer/showPlayer)
- Fixed audiobook_detail_screen _showPlayOnMenu (was using fixed 45% height)
- Added safe area padding to search_screen sheets

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 11:40:49 +00:00
Your NameandClaude Opus 4.5 3f9fb5d1e9 Bump version to 2.7.0-beta+30 for release
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 07:22:40 +00:00
Your NameandClaude Opus 4.5 62e6498b11 Change quick actions to match album view UI pattern
- Use expandable inline row instead of bottom sheet
- Long-press track to expand action buttons
- Actions: Radio (player picker), Add to Queue (player picker), Favorite (toggle)
- Matches existing album details screen behavior

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:55:36 +00:00
Your NameandClaude Opus 4.5 40dd26519d Remove voice search feature
Removed due to speech_to_text Gradle compatibility issues.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:35:06 +00:00
Your NameandClaude Opus 4.5 28da46ad05 fix: Downgrade speech_to_text to 6.6.0 for Gradle compatibility
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:33:21 +00:00
Your NameandClaude Opus 4.5 49cdf67c5c fix: Add RECORD_AUDIO permission for voice search
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:28:09 +00:00
Your NameandClaude Opus 4.5 2b6e1d73dc Add quick actions (long-press) for search results
- Add addTrackToQueue method to API and provider
- Add quick action bottom sheet with Play Now, Add to Queue, Play Radio
- Add onLongPress handler to track tiles in search
- Add localization strings for quick actions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:25:13 +00:00
Your NameandClaude Opus 4.5 40debeea06 docs: Update SEARCH-IMPROVEMENTS.md with completed tasks
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:33:30 +00:00
Your NameandClaude Opus 4.5 60a824d55a fix(search): Fix type errors in relevance scoring
- Use item is Album check before accessing inLibrary
- Compare favorite with == true (nullable bool)
- Use item.album?.name instead of albumName

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:33:02 +00:00
Your NameandClaude Opus 4.5 2897a4eaa4 feat(search): Add recent searches history
- Add SearchHistory table to database (schema v4)
- Save successful searches to history (deduplicated, max 10)
- Display recent searches as chips in empty state
- Clicking a chip performs that search
- Add localization strings for search history

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:32:06 +00:00
Your NameandClaude Opus 4.5 c36a971125 feat(search): Add smart cross-referencing for artists
When searching for a track or album title along with an artist name
(e.g., "Yesterday Beatles"), the search now includes related artists
from the matched tracks/albums even if they weren't in the direct
artist search results.

- Extract unique artists from matched tracks and albums
- Filter to artists whose name contains query words (min 3 chars)
- Add cross-referenced artists with lower relevance score (25)
- Avoids duplicates with direct search results

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:28:56 +00:00
Your NameandClaude Opus 4.5 f08f737b6f feat(search): Add unified relevance-based sorting for All results
- Implement relevance scoring algorithm for search results
  - Exact match: 100 points
  - Starts with query: 80 points
  - Word boundary match: 60 points
  - Contains query: 40 points
  - Fuzzy match: 20 points
  - Bonus for library items (+10) and favorites (+5)
  - Secondary field matching for albums/tracks (artist name)

- When viewing "All" filter, results are now merged and sorted by
  relevance score instead of grouped by type with headers

- Add type indicators to subtitles in unified view
  - Albums: "Artist • Album"
  - Tracks: "Artist • Track"
  - Playlists: "Owner • Playlist"
  - Audiobooks: "Author • audiobook"

- Add localization strings: albumSingular, trackSingular

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:20:55 +00:00
Your Name 1161dbb129 Fix PlaylistDetailsScreen constructor parameters 2025-12-27 18:53:38 +00:00
Your NameandClaude Opus 4.5 5d4fc5334c Add playlists and audiobooks to search results (#26)
- Parse playlists and audiobooks from MA search API response
- Add playlist and audiobook filter chips to search screen
- Add playlist and audiobook tiles with navigation to detail screens
- Update cache service and provider to include new media types

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 18:44:04 +00:00
Your Name f93494469d Merge fix/issue-27-external-source: External source detection (#27) 2025-12-27 18:19:26 +00:00
Your NameandClaude Opus 4.5 a94fcff758 Fix DLNA external source display bug (#27)
- Add activeSource and isExternalSource fields to Player model
- Detect external sources (optical, Spotify, AirPlay, etc.) from URI patterns
- Show "External Source" label instead of stale metadata
- Use cyan status indicator dot for external sources
- Hide play/pause button when external source is active (controls don't work)
- Clear cached track data when external source detected
- Add localization strings for "External Source" in EN/DE/ES

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 18:01:18 +00:00
Your NameandClaude Opus 4.5 1870724875 Fix Add to Queue button and player auto-selection issues
Issue #26.2: Add to Queue on artist pages
- Added null check for API before calling playArtistRadioToQueue()
- Shows "Not connected" error instead of silent failure

Issue #21: Player selection timing and auto-selection
- Added _sortPlayersSync() helper for reusable smart sorting
- Sort cached players immediately on app resume (no 5s delay)
- Added coldStart parameter to apply full priority logic on launch
- Priority: Playing player > Local player > Last selected > First

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 16:30:43 +00:00
Your NameandClaude Opus 4.5 bd3a2f98e8 Bump version to 2.6.1-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:12:39 +00:00
Your NameandClaude Opus 4.5 516de13894 Fix nav bar flash during first-time user onboarding
Add selectedPlayer check to nav bar visibility condition to prevent
it from showing during the ~1.2s gap between connection and player
selection for first-time users.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 14:35:57 +00:00
Your NameandClaude Opus 4.5 d7188a8784 Fix nav bar flash and backdrop color mismatch
1. Removed Selector for nav bar visibility - Selector only rebuilds when
   isConnected changes, but shouldShowWelcomeBackdrop is local state that
   can change independently. Now using simple if condition.

2. Changed backdrop color from colorScheme.surface to Color(0xFF1a1a1a)
   to match the connection screen exactly, ensuring seamless transition.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 14:18:05 +00:00
Your NameandClaude Opus 4.5 c03ea6a14b Hide bottom nav bar during welcome screen
The nav bar was appearing briefly before the welcome backdrop because
it only checked isConnected, not the welcome screen state. Now it also
hides when shouldShowWelcomeBackdrop is true.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 14:00:13 +00:00
Your NameandClaude Opus 4.5 d418af45f1 Fix LoginScreen to wait for player selection for first-time users
When a user logs in via LoginScreen (fresh install), the navigation to
HomeScreen was happening immediately on connection - before the player
was selected. This caused the home screen to flash before the welcome
overlay could appear.

For first-time users (hasCompletedOnboarding=false), LoginScreen now
waits for provider.selectedPlayer to be set before navigating, matching
the logic in AppStartup.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:44:13 +00:00
Your NameandClaude Opus 4.5 ac05381038 Show welcome backdrop immediately using computed condition
The backdrop was only showing when _isHintModeActive was true, but that
required setState which happens in a post-frame callback - causing a
1-frame gap where the home screen was visible.

Fix: Compute shouldShowWelcomeBackdrop synchronously in build():
- True when first-time user is ready (settings loaded + connected + player)
- True when already in hint mode
- This shows the backdrop IMMEDIATELY in the same frame as HomeScreen
- Welcome content (logo, text) still fades in via animation after

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:26:35 +00:00
Your NameandClaude Opus 4.5 4d29e32651 Fix welcome screen not triggering after connection
The welcome screen check in build() was missing - when the provider state
changes (connection happens), build() runs via context.watch(), but we
weren't checking if conditions were met for the welcome screen.

Added the check in build() with a post-frame callback to trigger the
welcome screen when:
- Settings are loaded
- First-time user (not completed onboarding)
- Connected with player selected

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:12:46 +00:00
Your NameandClaude Opus 4.5 91795976ff Fix welcome screen race condition by making AppStartup the gate
The home screen was briefly visible between connection and welcome screens
because AppStartup and GlobalPlayerOverlay were racing - they loaded settings
and detected connection independently with no synchronization.

Solution: Make AppStartup the single source of truth for the transition.
- Load onboarding settings FIRST (before connection check)
- For first-time users: wait for connected + player selected
- For returning users: wait for connected only
- Remove preemptive backdrop from GlobalPlayerOverlay (no longer needed)

This eliminates the race by design - the dark "Connecting..." screen stays
visible until the welcome overlay is ready to appear immediately.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:54:51 +00:00
Your NameandClaude Opus 4.5 484000bc6e Show backdrop during settings load to prevent home screen flash
Add _settingsLoaded flag to track async settings initialization.
Show solid backdrop when connected but settings not yet loaded,
ensuring the grey "Connecting..." background stays visible until
welcome screen is ready.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 11:23:16 +00:00
Your NameandClaude Opus 4.5 e373053f03 Fix welcome screen flash by using synchronous provider check
Use context.watch() directly in build method instead of nested Selector
to ensure the preemptive backdrop renders in the same frame as home screen.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 11:05:19 +00:00
Your NameandClaude Opus 4.5 31ef8edaf0 Fix preemptive backdrop not triggering rebuild
The _waitingForConnection flag was set without setState(), so the
preemptive backdrop condition was never re-evaluated. Now it properly
triggers a rebuild so the backdrop shows immediately when connected.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 10:43:47 +00:00
Your NameandClaude Opus 4.5 ff568ebf56 Add preemptive backdrop for seamless welcome transition
When connection is established, a solid backdrop now appears instantly
in the same render frame, covering the home screen before the animated
welcome screen starts. This prevents any flash of the home screen.

Flow: login → solid backdrop (instant) → blur + welcome content (animated)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 10:18:53 +00:00
Your NameandClaude Opus 4.5 ee6f00d56b Fix welcome screen to only show after successful login
The welcome screen was incorrectly starting immediately on first use,
even before the user had logged in. Now it correctly:

1. Shows login screen first (with hidden nav bar)
2. Waits for successful connection
3. Then shows welcome screen (covering home screen)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 10:04:05 +00:00
Your NameandClaude Opus 4.5 fff5c91b5c Improve welcome screen transition and hide nav bar on login
1. Hide bottom nav bar when not connected (fixes login screen issue)
2. Start welcome screen immediately on first use (blur + logo)
3. Show mini player hints only after connection is established

This ensures a smooth transition from login to home - the welcome
blur covers the home screen immediately, preventing any flash.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 09:47:35 +00:00
Your NameandClaude Opus 4.5 cd27aee7b8 Fix welcome screen showing before server connection
On fresh install, welcome screen was starting immediately even when
user hadn't configured server address/login yet. This meant the hints
were pointing to a mini player that wasn't visible.

Now the welcome screen waits until the user is connected with a
selected player before showing the onboarding experience.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 09:29:14 +00:00
Your NameandClaude Opus 4.5 8773ffa8c4 Welcome screen first-use only + bump version to 2.6.0-beta
- Welcome screen now only shows on first app launch
- Added hasCompletedOnboarding setting (separate from showHints)
- Onboarding marked complete when user skips or swipes to reveal
- Version bumped to 2.6.0-beta+28

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 07:18:48 +00:00
Your NameandClaude Opus 4.5 973652d970 Remove old dismiss hint and fix choppy player selector animation
- Remove "Swipe down, tap outside..." dismissal hint
- Separate backdrop into two widgets:
  - Reveal mode: static backdrop (no TweenAnimationBuilder overhead)
  - Hint mode: animated fade backdrop
- Fixes choppy expand/collapse by avoiding key changes and rebuilds

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:55:10 +00:00
Your NameandClaude Opus 4.5 054ad6ab0f Use theme surface color for welcome backdrop instead of black
Matches the dark grey background used elsewhere in the app

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:34:50 +00:00
Your NameandClaude Opus 4.5 e9cc8ce014 Update first-time player hint text
"Choose a player, or dismiss by swiping down"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:26:42 +00:00
Your NameandClaude Opus 4.5 0b8984ef20 Fix welcome screen skip and improve player selector hints
- Welcome screen: only skip button dismisses (not tapping anywhere)
- Player selector onboarding: show "Tap a player to start listening"
- Player selector regular use: show "Tap and hold a player to sync"
- Different icons for each context (touch_app vs lightbulb)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:23:38 +00:00
Your NameandClaude Opus 4.5 2d3c40aa67 Show artist on mini player when device selector is open
- Mini player secondary line now always shows artist/author
- Removed "Now Playing" text when device reveal is visible
- Consistent with showing actual track info

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:34:46 +00:00
Your NameandClaude Opus 4.5 d9946ae7da Hold black for 2 seconds, then fade over 1 second
- Welcome backdrop holds at 100% black for 2 seconds
- Then fades to 50% opacity over 1 second
- Uses Interval curve (0.67-1.0) for the delayed fade effect

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:32:45 +00:00
Your NameandClaude Opus 4.5 ab394c41a2 Start welcome screen fully black, fade to blur over 2 seconds
- Backdrop starts at 100% black opacity on first launch
- Fades to 50% opacity over 2 seconds (reveals blurred home screen)
- Hides any loading/rendering happening behind the scenes
- Reveal mode (device selector) uses instant 0.5 opacity (no fade)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:18:47 +00:00
Your NameandClaude Opus 4.5 229e47c31c Welcome screen opens immediately with smooth fade-in
- Start welcome overlay immediately on first launch (no delay)
- Add 800ms fade-in animation for welcome content
- Blur backdrop visible from start, hiding home screen load
- Hint text reduced to 16px (was 18px)
- Bounce animation starts after fade-in completes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:05:11 +00:00
Your NameandClaude Opus 4.5 e2218e8036 Fix welcome hint screen layout and typography
- Split welcome screen into two positioned sections:
  - Top: Logo and "Welcome to Ensemble" (anchored from top)
  - Bottom: Hint text and Skip button (anchored from bottom)
- Fix hint text using theme.textTheme.bodyLarge instead of raw TextStyle
- Create big visual gap between welcome title and hint text
- Equal 32px spacing between hint text <-> skip <-> mini player

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:45:09 +00:00
Your NameandClaude Opus 4.5 43ec560983 Adjust welcome screen: larger hint text and move logo up
- Hint text font size: 18px (was 14px)
- Move content up by 224px (1.5 album covers height)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:11:25 +00:00
Your NameandClaude Opus 4.5 ca1a7804a1 Update welcome screen layout with logo and unified backdrop
- Logo first, then "Welcome to Ensemble", then hint text
- Uses same logo as settings screen (50% width)
- Nicely spaced: 24px after logo, 16px after title, 32px before skip

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:44:17 +00:00
Your NameandClaude Opus 4.5 1f6e5128d0 Unify backdrop for seamless hint to player select transition
- GlobalPlayerOverlay now uses consistent 0.5 opacity for both states
- Removed PlayerRevealOverlay's own backdrop (was causing layered darkness)
- Transition from welcome screen to player select is now seamless

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:40:38 +00:00
Your NameandClaude Opus 4.5 cb8de0e253 Add two-stage onboarding with welcome screen and player hints
Stage 1 - Welcome screen:
- Darker blur backdrop (0.4 opacity) for better focus
- Welcome message above mini player explaining phone as default player
- Skip button to bypass onboarding
- Bouncing mini player continues until user acts

Stage 2 - Player selector hints:
- Shows dismissal hint when coming from onboarding
- "Swipe down, tap outside, or press back to return"

All new text strings are localized.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:19:51 +00:00
Your NameandClaude Opus 4.5 b3ab6db623 Fix hint not triggering when player has no track
The hint now triggers when a player is available, regardless of whether
there's a track playing. The 1500ms delay still gives time for any track
to load for a stable state.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:06:03 +00:00
Your NameandClaude Opus 4.5 8f2d947f4f Add favorite tracks cache and interactive hint mode
Favorite tracks:
- Cache favorite tracks to database for instant display on startup
- Load cached favorites when _tracks list is empty

Interactive hint mode:
- Blur/darken backdrop when hint triggers (like player selector)
- Mini player bounces every 2 seconds until user acts
- User can swipe down (reveals player selector) or tap/back to dismiss
- Single bounce animation (600ms) with text overlay

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:46:16 +00:00
Your NameandClaude Opus 4.5 f760d1ff29 Add instant home row loading from database cache
- Pre-load library from SyncService for instant favorites display
  - Favorites now filter from cached albums/artists immediately
  - No need to wait for server connection

- Add HomeRowCache table for discover rows persistence
  - Schema version 3 with migration
  - Stores recent albums, discover artists, discover albums

- Update CacheService to persist and restore home rows
  - loadHomeRowsFromDatabase() loads cached data on startup
  - setCached* methods now persist to database in background

- Update provider initialization order:
  1. Load players from database
  2. Load library from cache (for favorites)
  3. Load home rows from database (for discover)
  4. Then connect to server

This ensures all home screen rows load instantly on app launch,
including favorites and discover rows, without waiting for server.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:11:18 +00:00
Your NameandClaude Opus 4.5 440a369a67 Persist playback state for instant track display on app launch
- Fix hint timing to only fire when track is loaded (stable state)
  - Previously hint could fire before track loaded, getting lost in the UI
  - Now waits for currentTrack to be available before showing hint
  - Reduced delay from 3500ms to 500ms since we're waiting for track

- Wire up PlaybackState persistence for currentTrack
  - Load saved track from database on startup (_loadPlaybackStateFromDatabase)
  - Track displays immediately without waiting for server response
  - Persist track changes to database for next app launch
  - Clear persisted state when playback stops

This ensures the mini player shows the correct track info instantly when
the app opens, instead of going through multiple state transitions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 18:49:41 +00:00
Your NameandClaude Opus 4.5 aa9fd3628a Center hint text and make both bounces 20px
- Both bounces now 20px (was 20px + 12px)
- Hint text vertically centered in mini player
- Separate Positioned widget for hint vs track title

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 18:19:32 +00:00
Your NameandClaude Opus 4.5 a812d5c3bf Increase hint double bounce amplitude for visibility
- First bounce: 10px -> 20px
- Second bounce: 6px -> 12px
- Duration: 900ms -> 1000ms
- Single bounce for device selector unchanged at 10px

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 17:41:00 +00:00
Your NameandClaude Opus 4.5 0af5b3d981 Replace toast with inline hint on mini player
- Add isHintVisible prop to ExpandablePlayer
- When hint visible: show lightbulb icon + "Pull to select players" as primary text
- When hint visible: hide secondary text line (blank)
- Remove separate toast pill widget
- Hint text now appears directly in mini player, bouncing with it

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 17:37:44 +00:00
Your NameandClaude Opus 4.5 8a5186ac9d Fix hint system: restore bounces, fix toast styling
- Single bounce on device selector expand (when pulling down)
- Single bounce on device selector collapse (cards landing)
- Double bounce on mini player when hint appears
- Toast pill: no bounce, positioned over nav bar, proper font
- Added decoration: TextDecoration.none to prevent underlines

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 17:14:56 +00:00
Your NameandClaude Opus 4.5 0fbbf498e6 Fix: use onInverseSurface instead of inverseOnSurface
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 16:55:25 +00:00
Your NameandClaude Opus 4.5 9963f2f376 Refactor hint system: toast pill, hint-only bounce, fix hint text
- Remove double bounce from device selector expand/collapse
- Create separate hint-only bounce animation (doesn't affect player)
- Change pull-to-select hint to toast pill box at bottom of screen
- Update "hold to sync" text to "Tap and hold a player to sync"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 16:46:50 +00:00
Your NameandClaude Opus 4.5 1448da03b3 Adjust pull hint: move up 5px, delay 2s longer
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:55:18 +00:00
Your NameandClaude Opus 4.5 f2ee2c2c5c Fix pull-to-select hint position to half-overlap mini player
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:41:21 +00:00
Your NameandClaude Opus 4.5 1648910153 Add lightbulb icon to swipe-to-change-player hint
- Add isHint parameter to MiniPlayerContent
- Show lightbulb icon when secondary text is a hint
- Apply to both main and peek player content in DeviceSelectorBar

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:17:28 +00:00
Your NameandClaude Opus 4.5 c1fe9e2148 Add lightbulb icon to hold-to-sync hint
Consistent with pull-to-select hint styling

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:16:05 +00:00
Your NameandClaude Opus 4.5 d40941bf13 Improve pull-to-select hint with double bounce and lightbulb icon
- Reposition hint to half-overlap mini player top edge
- Add double bounce animation (first 10px, second 6px)
- Add lightbulb icon before hint text
- Extend animation duration to 900ms for better visibility

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:12:59 +00:00
Your NameandClaude Opus 4.5 01349f8336 Fix hint text styling and adjust timing
- Wrap hint text in Material widget to fix typewriter font/yellow underline
- Increase delay before showing hint: 500ms → 1500ms
- Increase linger time: 800ms → 2500ms

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 12:07:26 +00:00
Your NameandClaude Opus 4.5 439889fc3e Improve hint text styling and simplify swipe text
- Match hold-to-sync hint font size (16) with player card primary text
- Change text to "Hold a player to sync"
- Remove pill container from pull-to-select hint
- Make pull hint bounce with the mini player
- Simplify swipe text to just "Swipe to change player"
- Update German and Spanish translations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:52:53 +00:00
Your NameandClaude Opus 4.5 5f2ef72910 Power on Cast/Sendspin players before syncing
When long-pressing a powered-off Cast player that has a Sendspin
counterpart, automatically power it on first and wait for it to
become available before syncing. This provides a seamless experience
for grouping Cast devices that are currently off.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:42:19 +00:00
Your NameandClaude Opus 4.5 0f83bb842b Persist Cast-to-Sendspin ID mapping for offline grouping
Don't clear the _castToSendspinIdMap on each player refresh. This allows
syncing to work with powered-off Cast devices by remembering their
Sendspin IDs from when they were previously online.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:35:48 +00:00
Your NameandClaude Opus 4.5 5fc8623bcb Improve hint system and allow syncing powered-off players
- Fix hint text decoration to prevent underlines
- Make hold-to-sync hint slide with player cards instead of fading
- Show pull-down hint on every app launch (not just first time)
- Replace arrow symbols (← ↓ →) with text (left, down, or right)
- Allow long-press to sync players even when powered off
- Update translations for German and Spanish

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:28:30 +00:00
Your NameandClaude Opus 4.5 e3403c5daf Add hint system with settings toggle
- Add show hints toggle in new Hints & Tips settings section
- Add pull-to-select-players hint with bounce animation on first connection
- Add hold-to-sync hint in player reveal overlay
- Track when user has discovered player reveal feature
- Update swipe hint to show directional arrows: ← ↓ →
- Support English, German, and Spanish translations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:00:10 +00:00
Your Name 65e8e87e01 Bump version to 2.5.0-beta+27 2025-12-26 09:11:22 +00:00
Your NameandClaude Opus 4.5 2658be1a3f Add nowPlaying localization for device selector UI
Adds translations:
- English: "Now playing"
- German: "Wird gespielt"
- Spanish: "Reproduciendo ahora"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:24:57 +00:00
Your NameandClaude Opus 4.5 2a9a4b2494 Show player name in mini player when device selector is open
When the device reveal overlay is visible:
- Mini player shows player name instead of track name
- Secondary line shows "Now Playing" instead of artist
- Adds blur backdrop behind device selector for focus

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:15:47 +00:00
Your NameandClaude Opus 4.5 6ff1b8b95f Fix grouping: translate Cast player ID to Sendspin ID when syncing
When showing the regular Cast player (ungrouped state), sync operations
need to use the corresponding Sendspin player ID since only Sendspin
players can group with other Sendspin players.

Added _castToSendspinIdMap to track the mapping between regular Cast
and Sendspin player IDs, populated during player filtering.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 06:51:12 +00:00
Your NameandClaude Opus 4.5 06700f54c8 Smart Sendspin/Cast player switching based on group status
- When grouped: show Sendspin version (for sync), hide original Cast
- When ungrouped: show original Cast (for power control + queue)

This preserves power control and proper queue behavior when not syncing,
while still providing seamless Sendspin grouping when needed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 06:02:27 +00:00
Your NameandClaude Opus 4.5 489e2c3f3a Prefer Sendspin version of Cast players in player list
When a Cast device has Sendspin mode enabled, both the regular Cast
player and the "(Sendspin)" version appear in Music Assistant. This
change:
- Automatically prefers the Sendspin version when both exist
- Removes the "(Sendspin)" suffix for a cleaner display
- Provides seamless transitions when Sendspin becomes available

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 23:06:54 +00:00
Your NameandClaude Opus 4.5 60fb374d0f Fix Sendspin: always report synchronized state, rename timestamp field
- Change playerState from conditional 'synchronized'/'idle' to always 'synchronized'
  (MA only accepts 'synchronized', 'error', 'external_source')
- Rename 'timestamp' to 'client_transmitted' in time messages per protocol spec

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 22:40:58 +00:00
Your NameandClaude Opus 4.5 8946373a28 Reduce grouped border to 1.5px everywhere
Thinner border for a more subtle grouped indicator on:
- Mini player
- Player cards in reveal overlay
- Player selector sheet

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 10:00:55 +00:00
Your NameandClaude Opus 4.5 00560ed1ec Show leader's queue and track for grouped child players
When a player is synced/grouped as a child:
- getQueue() now returns the leader's queue
- getCachedTrackForPlayer() returns the leader's track
- selectPlayer() gets the leader's track from cache

This ensures grouped players show the same queue and track info
as their leader, reflecting the actual audio being played.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:40:56 +00:00
Your NameandClaude Opus 4.5 6b13e6e8f7 Add yellow border to mini player when grouped
Shows the same pastel yellow border (#FFF59D) on the mini player
that is shown on grouped player cards in the reveal overlay.
Only visible when collapsed (t < 0.5) to indicate group membership.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:38:28 +00:00
Your NameandClaude Opus 4.5 957cc170a8 Fix isGrouped to include child players with syncedTo
Child players (like Dining Room) have empty group_members but have
syncedTo set to the leader. Updated isGrouped to check for both:
- Leader: group_members.length > 1
- Child: syncedTo != null

Both should show the yellow border now.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:10:50 +00:00
Your NameandClaude Opus 4.5 91a00c62b0 Add long-press sync to PlayerCard in reveal overlay
The device list uses PlayerCard widget (not _PlayerSelectorSheet).
Added:
- onLongPress callback to PlayerCard
- isGrouped property for pastel yellow border
- Long-press handler in player_reveal_overlay.dart
- SnackBar feedback when long-press detected

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:41:39 +00:00
Your NameandClaude Opus 4.5 cb1122277a Add snackbar feedback for long-press sync detection
- Show visual snackbar "Syncing {player}..." when long-press detected
- Remove !isSelected condition to allow sync on any available player
- Helps debug if long-press gesture is being recognized

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:39:46 +00:00
Your NameandClaude Opus 4.5 190fa4f61c Add detailed logging for player sync debugging
Logs added at:
- UI: Long-press trigger with player details
- Provider: togglePlayerSync, syncPlayerToSelected
- API: syncPlayerToLeader with request/response

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:29:35 +00:00
Your NameandClaude Opus 4.5 2c02473f32 Fix sync API command: use players/cmd/group
The correct MA API command is:
- players/cmd/group (not group_many)
- player_id: the player joining the group
- target_player: the leader player to sync to

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:05:52 +00:00
Your NameandClaude Opus 4.5 79962517ae Add temporary player grouping via long-press
Feature: Long-press on a player in the device list to sync it with the
currently selected/playing player, creating a temporary group.

Changes:
- API: Add syncPlayers() and unsyncPlayer() methods using MA commands
  players/cmd/group_many and players/cmd/ungroup
- Model: Add groupMembers and syncedTo fields to Player, with derived
  properties isGrouped, isGroupLeader, isGroupChild
- Provider: Add syncPlayerToSelected(), unsyncPlayer(), togglePlayerSync()
- UI: Long-press gesture on player cards (with haptic feedback)
- UI: Pastel yellow border (#FFF59D) around grouped players
- UI: "Synced" badge with link icon for grouped players

UX Flow:
1. Play music on a speaker (e.g., Kitchen HiFi)
2. Open device selector
3. Long-press another speaker (e.g., Bedroom)
4. Bedroom syncs to Kitchen and both show yellow border
5. Long-press again to unsync

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:25:39 +00:00
Your NameandClaude Opus 4.5 8641c89b9b Fix badge count and auto-selection priority logic
- Badge now excludes selected player from count (shows "other" activity)
- Auto-selection now switches to playing player when preferLocalPlayer is OFF
  and current selection is not playing

Addresses feedback from issue #21

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:21:27 +00:00
Your Name 8f1a1cd3bd Bump version to 2.4.1-beta+26 2025-12-24 21:50:59 +00:00
Your NameandClaude Opus 4.5 c841898d8c Add image pre-caching to album and artist rows
The root cause: second tap is smooth because image is cached,
but first tap shows grey flash because image isn't cached yet.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Part of aggressive caching overhaul for instant app resume.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 20:15:35 +00:00
Your NameandClaude Opus 4.5 d7bfb66fab fix: Reduce play button nudge to add space from skip
Play: +3px, Skip: +6px (more space between play and skip)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:56:19 +00:00
Your NameandClaude Opus 4.5 222a66db61 fix: Nudge play and skip buttons toward power
Move play and skip 6px right instead of moving power left

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:38:55 +00:00
Your NameandClaude Opus 4.5 8eec9599f3 fix: Revert to Material Icons, use Transform to close gap
- Reverted MDI icons back to Material Icons
- Used Transform.translate(-6, 0) to nudge power button left
- This closes the gap between skip and power without changing icon pack

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:37:41 +00:00
Your NameandClaude Opus 4.5 fe45a34c35 feat: Switch to MDI icons for tighter spacing
Replace Material Icons with Material Design Icons (MDI) in:
- Device list: play/pause, skip next, power
- Mini player: skip previous, play/pause, skip next
- Expanded player: shuffle, repeat, skip controls, audiobook controls

MDI icons have less built-in whitespace for more compact layouts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:21:54 +00:00
Your NameandClaude Opus 4.5 232862b164 fix: Remove gap between skip and power buttons
Set padding to zero on both buttons for minimal spacing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:07:37 +00:00
Your NameandClaude Opus 4.5 dc6004ac00 feat: Asymmetric device list animation timing
- Expand: 200ms (slower, more deliberate reveal)
- Collapse: 150ms (snappier dismiss)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:00:34 +00:00
Your NameandClaude Opus 4.5 62fc5ab2f0 fix: Reduce gap between skip and power buttons
Reduced power button horizontal padding from 4 to 2

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:51:39 +00:00
Your NameandClaude Opus 4.5 807bebbab9 fix: Restore skip button to original size
Keep skip next at 28, only reduce play (28) and power (20)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:05:29 +00:00
Your NameandClaude Opus 4.5 7edf7bbfc8 fix: Reduce device selector button sizes for better alignment
- Play/Pause: 34→28 icon size, smaller container
- Skip next: 28→24 icon, reduced horizontal padding
- Power button: 28→20 (smallest of the three)
- Reduced gaps between buttons for more compact layout

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:03:26 +00:00
Your NameandClaude Opus 4.5 c4ad0a6279 fix: Match play button sizing exactly to mini player
Use SizedBox wrapper with zero padding for play button, matching
the mini player's Container approach. Also restore right margin to 4px.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:39:12 +00:00
Your NameandClaude Opus 4.5 6ebf2ed15b feat: Update device list buttons to play/pause, next, power
- Change button order from prev/play/next to play/pause/next/power
- Match mini player button sizes (play=34, skip/power=28)
- Power button always visible when player available (dimmed when off)
- Play/pause and next only shown when powered with content

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:23:16 +00:00
Your NameandClaude Opus 4.5 91b713aab8 fix: Back gesture priority for device list over navigation
Add device list (player reveal) to didPopRoute() priority chain.
Previously back gesture would pop navigation routes before dismissing
the device list when on screens other than home.

Priority order is now: device list > expanded player > navigation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:55:26 +00:00
Your NameandClaude Opus 4.5 8ce567a1cf perf: Revert TweenSequence to simple CurvedAnimation for smoother FPS
TweenSequence with chained curves was causing low FPS on the mini player
to expanded player animation. Reverted to simple CurvedAnimation which
is more performant:

- Replace 3-phase TweenSequence with single CurvedAnimation (easeOutCubic)
- Reduce expand duration from 350ms to 280ms
- Simplify bounce animation back to sine-wave calculation
- Keep gesture-driven expand/collapse functionality

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:41:23 +00:00
Your NameandClaude Opus 4.5 2081cf8552 fix: Device list swipe-down and back gesture handling
- Fix swipe-down gesture on active mini player to reveal device list
  (was blocked by gesture tracking starting immediately)
- Fix back gesture priority: device list now closes before underlying
  album/artist screens by handling PopScope at GlobalPlayerOverlay level

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 10:37:10 +00:00
Your NameandClaude Opus 4.5 8dae062663 fix: Correct swipe direction and faster collapse
- Fix inverted drag: swipe down now moves cards down (toward dismiss)
- Faster collapse animations:
  - Mini player: 250ms → 200ms
  - Player list: 200ms → 150ms

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:33:32 +00:00
Your NameandClaude Opus 4.5 59eb80c11b fix: Animation refinements based on testing feedback
- Faster collapse (250ms vs 350ms expand) for snappier feel
- Simplified bounce to single clean motion (down 10px → back)
  - Removed overshoot phases that looked like multiple bounces
  - Duration reduced to 200ms
- Fixed swipe dismiss on player list vanishing halfway
  - Reset dragOffset before dismiss() so animation starts correctly

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:13:55 +00:00
Your NameandClaude Opus 4.5 d118dd8fe1 feat: Gesture-driven expand/collapse with keyframe animations
- Replace threshold-based expand/collapse with finger-tracking
  - Mini player now follows finger during vertical swipe
  - Snap decision based on velocity (800px/s) and position (30%)

- Add TweenSequence for phased animations
  - Phase 1 (0-40%): Quick acceleration with easeOut
  - Phase 2 (40-75%): Consistent cruise with linear
  - Phase 3 (75-100%): Settle with slight overshoot (easeOutBack)

- Fix position calculation discontinuity
  - Removed abrupt switch at t=0.1
  - Now smoothly fades out slide/bounce offsets over first 15%

- Add settle bounce when cards collapse into mini player
  - TweenSequence bounce: push down 12px → overshoot -4px → settle
  - Triggered when cards are 70% collapsed for natural "landing" feel

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 08:53:38 +00:00
Your Name 64b36d37ad Revert test: restore normal mini player colors 2025-12-23 07:32:09 +00:00
Your Name 339e55df93 TEST: Bright red mini player background to verify build freshness 2025-12-23 07:13:48 +00:00
Your Name 86f3798637 Add unified device UI with swipe-down reveal animation
Cherry-picked commits 39f43cc through d0ffd4a from feature/unified-device-animation
to get clean build state before Gradle issues.
2025-12-23 06:57:44 +00:00
Your NameandClaude Opus 4.5 d5c96880d7 chore: Bump version to 2.3.3-beta+25
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 10:10:43 +00:00
Your NameandClaude Opus 4.5 540b95f558 Add MA version detection for Sendspin proxy support
- Add _parseServerVersion() helper for version comparisons
- Add _serverHasSendspinProxy() to detect MA 2.8.0+ (has /sendspin proxy)
- Add _logSendspinTroubleshooting() with version-specific guidance
- Update connection logic to try local proxy fallback for local IPs
- Improve logging to show server version and proxy availability
- MA 2.7.x users now get clear guidance: upgrade to 2.8.0+ or configure proxy

Fixes #17

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 21:50:54 +00:00
Your Name a1c84f29c1 fix: Fix followRedirects usage and add missing painting import 2025-12-21 21:00:02 +00:00
Your Name 2cf3135f18 fix(ci): Add flutter gen-l10n step to build workflow 2025-12-21 20:46:21 +00:00
Your NameandClaude Opus 4.5 1b33c57cb7 fix: Improve local IP and Sendspin connection handling
Fixes issue #17 - users unable to connect via local IP or see local player

Changes:
1. **Local IP connection handling**:
   - Don't follow HTTP->HTTPS redirects for local IPs
   - Detect 308/301/302 redirects and handle gracefully
   - Prevents certificate verification errors when server redirects to HTTPS

2. **Sendspin connection logic**:
   - Remove non-existent API calls (sendspin/connection_info, webrtc_offer, etc.)
   - For local IPs: Connect directly to ws://ip:8927/sendspin
   - For external/HTTPS: Use proxy at wss://server/sendspin with auth
   - Better error messages explaining what to configure

3. **Utility improvements**:
   - Add _isLocalNetworkHost() helper for IP detection
   - Include .ts.net (Tailscale) as local network

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 20:35:52 +00:00
126 changed files with 39012 additions and 5933 deletions
+19 -1
View File
@@ -35,12 +35,25 @@ jobs:
channel: 'stable'
cache: true
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Get dependencies
run: flutter pub get
- name: Generate code (Drift database)
run: dart run build_runner build --delete-conflicting-outputs
- name: Generate localizations
run: flutter gen-l10n
- name: Generate launcher icons
run: flutter pub run flutter_launcher_icons
@@ -62,7 +75,12 @@ jobs:
EOF
- name: Build APK
run: flutter build apk --release
run: |
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
mkdir -p ~/.gradle
cp android/init.gradle ~/.gradle/init.gradle
flutter build apk --release
- name: Upload APK artifact
uses: actions/upload-artifact@v4
+182
View File
@@ -0,0 +1,182 @@
# Player System Animation & Polish Audit
## Context
This is a Music Assistant client app (Ensemble) built with Flutter. The player system has undergone extensive development and now needs a comprehensive polish audit. The goal is buttery smooth 60fps animations and a premium user experience.
## Architecture Overview
The player system consists of three interconnected components:
1. **Mini Player** (collapsed state) - Floating card at bottom, shows current track
2. **Expanded Player** - Full-screen player with album art, controls, progress
3. **Queue Panel** - Slides in from right of expanded player, shows playback queue
Additionally:
- **Player Selector** - Horizontal swipe between multiple players (collapsed mode)
- **Device Reveal** - Swipe down from mini player to show available players
## Key Files
```
lib/widgets/
├── expandable_player.dart # Main player widget (1800+ lines)
├── global_player_overlay.dart # Overlay wrapper, static accessors
├── player/
│ ├── queue_panel.dart # Queue list with great_list_view
│ ├── chapters_panel.dart # Audiobook chapters
│ └── mini_player_content.dart
```
## CRITICAL CONSTRAINTS
1. **DO NOT MODIFY the queue list implementation** - It uses `great_list_view` package with `AutomaticAnimatedListView`. This was chosen specifically to avoid Flutter's grey screen overlay bug (#103804). The current implementation works correctly.
2. **Preserve all existing functionality** - Focus on polish, not rewrites
3. **Test changes incrementally** - This system is complex with many interdependencies
## Audit Areas
### 1. Animation Performance Audit
Use sub-agents to analyze each animation system:
**Agent 1: Expand/Collapse Animation**
- Analyze `_controller` and `_expandAnimation`
- Check for jank during morphing transition
- Verify lerp functions are efficient
- Look for unnecessary rebuilds during animation
- Check `RepaintBoundary` placement
**Agent 2: Queue Panel Animation**
- Analyze `_queuePanelController` and `_queuePanelAnimation`
- Verify slide-in/out is smooth
- Check interaction with expanded player state
- Verify the Listener-based swipe detection doesn't cause issues
**Agent 3: Player Selector Animation**
- Analyze horizontal swipe between players
- Check `_slideOffset` and peek player animations
- Verify finger-following feels natural
- Look for edge cases with rapid swipes
**Agent 4: Micro-interactions**
- Play/pause button animations
- Progress bar interactions
- Volume swipe overlay
- Favorite button feedback
- Skip button responsiveness
### 2. State Management Audit
**Check for:**
- Unnecessary `setState` calls during animations
- ValueNotifier usage efficiency
- Animation listener cleanup
- Memory leaks from subscriptions
- State synchronization between components
### 3. Visual Polish Audit
**Check for:**
- Color transitions during expand/collapse
- Text fade/scale animations
- Image loading placeholders
- Shadow/elevation consistency
- Border radius morphing smoothness
- Safe area handling
### 4. Gesture Conflict Resolution
**Verify:**
- Vertical drag (expand/collapse) vs horizontal drag (player switch)
- Queue panel swipe-right-to-close vs Dismissible swipe-left-to-delete
- Edge dead zones for Android back gesture
- Tap vs drag disambiguation
### 5. Edge Cases
**Test scenarios:**
- Rapid expand/collapse
- Swipe during animation
- Back button during animations
- Track change during queue scroll
- Network image loading failures
- Very long track/artist names
## Execution Strategy
```
ultrathink: true
```
### Phase 1: Discovery (Read-Only)
Launch parallel sub-agents to analyze each file:
- Read and understand the animation architecture
- Map all animation controllers and their relationships
- Identify potential performance bottlenecks
- Document current RepaintBoundary usage
### Phase 2: Analysis Report
Compile findings into categories:
- **Critical**: Causes visible jank or bugs
- **Important**: Noticeable but not breaking
- **Nice-to-have**: Minor polish improvements
### Phase 3: Implementation Plan
For each finding:
- Specific file and line numbers
- Proposed change
- Risk assessment
- Testing approach
### Phase 4: Incremental Fixes
- One fix at a time
- Build and test after each change
- Verify no regressions
## Output Format
Provide a structured audit report:
```markdown
## Animation Audit Report
### Executive Summary
[2-3 sentences on overall state]
### Critical Issues
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
### Important Issues
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
### Polish Opportunities
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
### Architecture Observations
[Notes on overall structure, patterns noticed]
### Recommended Priority Order
1. [First fix]
2. [Second fix]
...
```
## Success Criteria
After this audit and fixes:
- All animations run at consistent 60fps
- No jank during any transition
- Gestures feel responsive and natural
- State transitions are seamless
- No visual glitches or flickers
- Memory usage is stable during interactions
## Notes
- The app uses adaptive theming (colors extracted from album art)
- There's an `AnimationDebugger` utility for profiling
- Position tracking uses a shared `PositionTracker` singleton
- The player supports both music and audiobooks (different UI modes)
+391
View File
@@ -0,0 +1,391 @@
# Ensemble Codebase Intensive Audit Prompt
## Overview
You are tasked with conducting an intensive, comprehensive audit of the **Ensemble** Flutter application - a Music Assistant client for Android with multi-device synchronization, local playback, and Sendspin raw audio streaming capabilities.
**Codebase Statistics:**
- 90 Dart files, ~40,583 lines of code
- 18 screens, 27 services, 6 providers
- Architecture: Provider pattern with ChangeNotifier
- Audio: just_audio + flutter_pcm_sound + audio_service
- Networking: WebSocket (JSON-RPC) + HTTP + Sendspin binary protocol
---
## Audit Scope
Perform a thorough audit covering ALL of the following areas. For each issue found, provide:
1. **File path and line number(s)**
2. **Severity**: CRITICAL / HIGH / MEDIUM / LOW
3. **Description** of the issue
4. **Impact** if left unfixed
5. **Recommended fix** with code example where applicable
---
## SECTION 1: SECURITY AUDIT
### 1.1 Credential Storage (CRITICAL PRIORITY)
Audit `/lib/services/settings_service.dart` for:
- [ ] Plaintext password storage in SharedPreferences (lines 209-222)
- [ ] Auth tokens stored without encryption
- [ ] Serialized credentials in JSON format (lines 169-190)
**Expected Finding:** Credentials stored insecurely. Recommend flutter_secure_storage.
### 1.2 Certificate Validation
Audit `/lib/services/auth/auth_manager.dart` for:
- [ ] HTTPS redirect handling for local IPs
- [ ] Certificate validation disabled scenarios
- [ ] MITM vulnerability exposure
### 1.3 Authentication Strategies
Audit all files in `/lib/services/auth/`:
- [ ] `basic_auth_strategy.dart` - Base64 encoding (not encryption)
- [ ] `authelia_strategy.dart` - Session cookie handling, timing attack (line 159)
- [ ] `ma_auth_strategy.dart` - Token refresh mechanism
- [ ] `no_auth_strategy.dart` - Public server exposure
### 1.4 Input Validation
Audit `/lib/services/music_assistant_api.dart`:
- [ ] URI construction without validation (lines 1346-1360)
- [ ] Provider/itemId injection risks
- [ ] WebSocket message validation
### 1.5 Information Disclosure
- [ ] Debug logs exposing sensitive data (check all `_logger.log()` calls)
- [ ] Server version exposure via `server_info`
- [ ] Error messages leaking technical details
---
## SECTION 2: MEMORY MANAGEMENT AUDIT
### 2.1 Static Memory Leaks
Audit `/lib/models/player.dart`:
- [ ] `_playerCreationTimes` static map (lines 96-144) - grows unbounded
- [ ] Cleanup only removes 50 of 100+ entries
### 2.2 Unbounded Caches
Audit `/lib/services/cache_service.dart`:
- [ ] `_albumTracksCache` - no size limit
- [ ] `_artistAlbumsCache` - no TTL expiration
- [ ] `_searchCache` - never evicted
- [ ] `_playerTrackCache` - only cleared on disconnect
### 2.3 Stream/Timer Cleanup
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] `_playerStateTimer` disposal (line 4435-4445)
- [ ] `_notificationPositionTimer` multiple creation paths (lines 2925-2940)
- [ ] `_localPlayerEventSubscription` cleanup
- [ ] `_playerUpdatedEventSubscription` cleanup
- [ ] `_playerAddedEventSubscription` cleanup
Audit `/lib/services/hardware_volume_service.dart`:
- [ ] `_volumeUpController` never closed (lines 74-85)
- [ ] `_volumeDownController` never closed
### 2.4 Stream Subscription Leaks
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] `_api!.connectionState.listen()` - subscription not stored
- [ ] Old subscriptions not cancelled before new API instance
---
## SECTION 3: RACE CONDITION AUDIT
### 3.1 Player State Dual Updates
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] Timer polling (every 5s) vs WebSocket events racing
- [ ] `_updatePlayerState()` called from both paths
- [ ] No mutex/lock protection
### 3.2 Async Method Reentrancy
Audit `selectPlayer()` method:
- [ ] `async` method without guard against concurrent calls
- [ ] `_selectedPlayer` modified before and after `await`
- [ ] State could change during await
### 3.3 Fire-and-Forget Operations
Search for `unawaited(` and `() async {` patterns:
- [ ] `_persistPlaybackState()` - database save not awaited
- [ ] Pause operations fire-and-forget (lines 3876-3894)
- [ ] Preload operations (line 2707)
### 3.4 PCM Player State Machine
Audit `/lib/services/pcm_audio_player.dart`:
- [ ] 9-state machine with complex transitions (lines 32-37)
- [ ] Auto-recovery race condition (lines 215-229)
- [ ] Feed loop during pause transition (lines 174-179)
---
## SECTION 4: ERROR HANDLING AUDIT
### 4.1 Silent Exception Handlers (CRITICAL)
Find all instances of:
```dart
} catch (e) {}
} catch (_) {}
```
Known locations:
- [ ] `music_assistant_provider.dart` (multiple)
- [ ] `player_provider.dart` (3 instances)
- [ ] `recently_played_service.dart`
### 4.2 Missing Error Recovery
- [ ] No exponential backoff on connection retry
- [ ] No retry logic for failed API calls
- [ ] WebSocket timeout handling (30 seconds - too long?)
### 4.3 Null Safety Violations
Find all `!` (null assertion) operators:
- [ ] `search_screen.dart` (20+ instances)
- [ ] `models/media_item.dart`
- [ ] `models/player.dart`
- [ ] `item.mediaItem! as Artist` patterns
---
## SECTION 5: ARCHITECTURAL ISSUES
### 5.1 God Class
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] 4,469 lines - violates Single Responsibility
- [ ] 386 conditional branches
- [ ] Mixing: connection, players, library, sync, audio, position tracking
**Recommend splitting into:**
- ConnectionProvider (~500 LOC)
- PlayerManager (~800 LOC)
- LibraryProvider (~1000 LOC)
- LocalPlaybackManager (~600 LOC)
### 5.2 Oversized Widget Files
- [ ] `expandable_player.dart` - 2,485 lines (should be <500)
- [ ] `global_player_overlay.dart` - 737 lines
- [ ] `new_library_screen.dart` - 2,521 lines
### 5.3 Global Key Anti-Pattern
Audit `/lib/widgets/global_player_overlay.dart`:
- [ ] `globalPlayerKey` for state access
- [ ] `_overlayStateKey` for overlay state
- [ ] Should use Provider/context instead
### 5.4 Primitive Obsession
- [ ] View modes as strings ('grid2', 'grid3', 'list')
- [ ] Media types as strings ('track', 'album', 'artist')
- [ ] Should be strongly-typed enums
---
## SECTION 6: CODE QUALITY ISSUES
### 6.1 Testing (CRITICAL)
- [ ] **ZERO test files exist** - confirm this
- [ ] No unit tests, widget tests, or integration tests
- [ ] flutter_test dependency exists but unused
### 6.2 Hardcoded Values
Find and document all:
- [ ] Color hex codes (50+ instances of `Color(0xFF...`)
- [ ] Duration values not using `Timings` constants
- [ ] Magic numbers (16, 4, 5, 100, 50)
- [ ] String literals for media types
### 6.3 Mixed Async Patterns
- [ ] `.then()` chains alongside async/await
- [ ] `.whenComplete()` usage
- [ ] Consolidate to async/await
### 6.4 Dead Code
- [ ] `@Deprecated` methods still implemented
- [ ] `animation_debugger.dart` - dev-only?
- [ ] Unused imports
### 6.5 TODO Comments
Document all actionable TODOs:
- [ ] `queue_screen.dart:214` - queue item removal
- [ ] `audiobook_detail_screen.dart:261,312` - progress sync
- [ ] `music_assistant_provider.dart:793,799` - playlist modification
---
## SECTION 7: UI/UX AUDIT
### 7.1 Accessibility (CRITICAL)
Audit for missing:
- [ ] `Semantics` widgets on interactive elements
- [ ] `Tooltip` on icon buttons
- [ ] Screen reader support
- [ ] Touch target sizes (minimum 48dp)
- [ ] Color contrast validation (WCAG AA)
### 7.2 Performance Issues
Audit `/lib/widgets/expandable_player.dart`:
- [ ] No `RepaintBoundary` wrappers
- [ ] 55+ `setState()` calls
- [ ] 3 AnimationControllers with multiple listeners
- [ ] Title height cache invalidation (lines 118+)
### 7.3 Double Opacity Bug
Audit `/lib/widgets/player/player_controls.dart`:
```dart
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
```
- [ ] Double `.withOpacity()` application
### 7.4 Missing Features
- [ ] No landscape orientation support
- [ ] No tablet layout optimization
- [ ] No loading skeleton animations
- [ ] No haptic feedback (except one location)
---
## SECTION 8: AUDIO PLAYBACK AUDIT
### 8.1 Position Interpolation
Audit `/lib/services/position_tracker.dart`:
- [ ] No timeout on anchor updates - unbounded drift possible
- [ ] Duration interpolation can exceed track duration
- [ ] Stale timestamp handling (>30 seconds)
### 8.2 Sendspin Protocol
Audit `/lib/services/sendspin_service.dart`:
- [ ] Proxy auth retry without deduplication (lines 188-209)
- [ ] WebSocket binary frame parsing
- [ ] Clock synchronization accuracy
### 8.3 Audio Resource Cleanup
Audit `/lib/services/audio/massiv_audio_handler.dart`:
- [ ] Audio session interrupt listeners not disposed
- [ ] Audio focus not explicitly released
### 8.4 Mixed Playback Modes
- [ ] just_audio initialized but unused during Sendspin
- [ ] Resource allocation for unused player
- [ ] Potential audio focus conflicts
---
## SECTION 9: NETWORKING AUDIT
### 9.1 WebSocket Lifecycle
Audit `/lib/services/music_assistant_api.dart`:
- [ ] Heartbeat interval (30s) - appropriate?
- [ ] Reconnection logic and timing
- [ ] Pending request cleanup on disconnect
### 9.2 Caching Strategy
- [ ] No incremental/delta sync (full library every 5 minutes)
- [ ] Cache staleness not communicated to UI
- [ ] No query result pagination (fetches up to 5000 items)
### 9.3 Rate Limiting
- [ ] No client-side rate limiting
- [ ] Search requests not throttled
- [ ] Could overload server
### 9.4 Offline Support
Audit `/lib/services/offline_action_queue.dart`:
- [ ] Queue execution not atomic (lines 118-130)
- [ ] Action could execute twice on crash
- [ ] Queue stored unencrypted
---
## SECTION 10: DATABASE AUDIT
### 10.1 Schema Review
Audit `/lib/database/database.dart`:
- [ ] Table indexes for query performance
- [ ] Foreign key constraints
- [ ] Migration strategy
### 10.2 Query Patterns
- [ ] N+1 query prevention
- [ ] Transaction usage for atomic operations
- [ ] Connection pooling
### 10.3 Data Consistency
- [ ] Tier 1 (DB) vs Tier 2 (API sync) race conditions
- [ ] User modifications during background sync
- [ ] Conflict resolution strategy
---
## Deliverables
After completing the audit, provide:
### 1. Executive Summary
- Overall code quality score (1-10)
- Top 5 critical issues requiring immediate attention
- Estimated technical debt in developer-hours
### 2. Issue Tracker
Create a prioritized list of all issues found:
| ID | Severity | Category | File:Line | Description | Effort |
|----|----------|----------|-----------|-------------|--------|
### 3. Recommended Roadmap
Sprint-based plan to address findings:
- Sprint 1 (Immediate): Security fixes, silent exception handlers
- Sprint 2-3: God class decomposition, test infrastructure
- Sprint 4-6: Memory leak fixes, accessibility
- Long-term: Performance optimization, monitoring
### 4. Code Examples
For each CRITICAL and HIGH severity issue, provide:
- Current problematic code
- Recommended refactored code
- Migration steps
---
## Files to Prioritize
Start your audit with these critical files:
1. `/lib/providers/music_assistant_provider.dart` (4,469 LOC) - God class
2. `/lib/services/settings_service.dart` - Credential storage
3. `/lib/services/music_assistant_api.dart` (2,929 LOC) - Networking
4. `/lib/widgets/expandable_player.dart` (2,485 LOC) - UI complexity
5. `/lib/services/auth/` directory - Security
6. `/lib/models/player.dart` - Memory leaks
7. `/lib/services/cache_service.dart` - Unbounded caches
8. `/lib/services/position_tracker.dart` - Audio sync issues
---
## Known Critical Issues (Pre-Identified)
These issues have been identified and MUST be verified and documented:
1. **CRITICAL: Plaintext credential storage** - SharedPreferences without encryption
2. **CRITICAL: Zero test coverage** - No unit, widget, or integration tests
3. **CRITICAL: 4,469 line god class** - MusicAssistantProvider violates SRP
4. **CRITICAL: Silent exception handlers** - `catch (e) {}` patterns
5. **HIGH: Static memory leak** - `_playerCreationTimes` unbounded growth
6. **HIGH: Race conditions** - Player state polling vs events
7. **HIGH: No accessibility** - Missing Semantics, screen reader support
8. **HIGH: Certificate validation disabled** - MITM vulnerability for local IPs
9. **MEDIUM: Double opacity bug** - `.withOpacity().withOpacity()` in player controls
10. **MEDIUM: Position tracker drift** - No timeout on anchor updates
---
## Audit Execution Notes
- Use `Grep` tool to search for patterns across the codebase
- Use `Read` tool to examine specific file sections
- Document line numbers for all findings
- Provide code snippets for context
- Cross-reference related issues across files
- Verify pre-identified issues exist and document their exact locations
**Total estimated audit time: 4-6 hours for thorough coverage**
+419
View File
@@ -0,0 +1,419 @@
# Ensemble Codebase - Comprehensive Audit Report
**Audit Date:** 2026-01-01
**Codebase:** Ensemble Flutter App (Music Assistant Client)
**Version:** 2.7.3-beta+35
**Total Lines of Code:** ~40,583 Dart
**Files Analyzed:** 90 Dart files
---
## Executive Summary
### Overall Code Quality Score: 5.5/10
The Ensemble codebase demonstrates **strong architectural foundations** with a well-organized service layer, effective caching strategies, and sophisticated audio handling. However, the audit revealed **critical security vulnerabilities**, **zero test coverage**, and significant **technical debt** that must be addressed before production release.
### Critical Statistics
| Metric | Value | Status |
|--------|-------|--------|
| Test Coverage | 0% | CRITICAL |
| God Class Size | 4,469 LOC | CRITICAL |
| Silent Exception Handlers | 7 | CRITICAL |
| Plaintext Credentials | 4 locations | CRITICAL |
| Memory Leak Locations | 6 | HIGH |
| Race Conditions | 4 | HIGH |
| Accessibility (Semantics) | 0 widgets | CRITICAL |
---
## Issue Summary by Severity
| Severity | Count | Categories |
|----------|-------|------------|
| **CRITICAL** | 12 | Security, Testing, Architecture, Accessibility |
| **HIGH** | 18 | Memory, Race Conditions, Networking, Audio |
| **MEDIUM** | 22 | Error Handling, UI, Caching, Code Quality |
| **LOW** | 14 | Naming, Documentation, Minor Improvements |
---
## CRITICAL ISSUES (Immediate Action Required)
### 1. Plaintext Credential Storage
**File:** `/lib/services/settings_service.dart`
**Lines:** 131-143, 147-159, 169-190, 209-222
Passwords and auth tokens stored in SharedPreferences without encryption.
```dart
// VULNERABLE CODE
static Future<void> setPassword(String? password) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyPassword, password); // PLAINTEXT!
}
```
**Fix:** Use `flutter_secure_storage` for all sensitive data.
---
### 2. Zero Test Coverage
**Location:** Entire codebase
No unit tests, widget tests, or integration tests exist. The `flutter_test` dependency is unused.
**Impact:** Cannot safely refactor, high regression risk, no confidence in correctness.
**Fix:** Establish test infrastructure immediately. Start with:
- Unit tests for `MusicAssistantAPI`, `CacheService`, `ErrorHandler`
- Widget tests for player controls
- Integration tests for playback flows
---
### 3. God Class - MusicAssistantProvider
**File:** `/lib/providers/music_assistant_provider.dart`
**Size:** 4,469 lines, ~140 methods, 744 conditional branches
Single class handles: connection, players, library, cache, audio, sync, offline queue.
**Fix:** Split into:
- `ConnectionManager` (~500 LOC)
- `PlayerManager` (~800 LOC)
- `LibraryCacheService` (~600 LOC)
- `SendspinManager` (~400 LOC)
- `MusicAssistantProvider` - Facade composing above (~800 LOC)
---
### 4. Zero Accessibility Support
**Files:** `/lib/widgets/`, `/lib/screens/` (all)
- **0** `Semantics` widgets
- **0** `semanticLabel` properties
- **1** `Tooltip` (entire app)
- Screen readers cannot navigate the app
**Fix:** Add Semantics to all interactive elements:
```dart
Semantics(
button: true,
label: isPlaying ? 'Pause playback' : 'Play',
child: IconButton(...)
)
```
---
### 5. Silent Exception Handlers
**Locations:** 7 instances across codebase
| File | Line | Code |
|------|------|------|
| player_provider.dart | 567, 575, 584 | `catch (e) {}` |
| music_assistant_provider.dart | 2690 | `catch (e) {}` |
| music_assistant_api.dart | 2542 | `catch (e) {}` |
| recently_played_service.dart | 154, 206 | `catch (_) {}` |
**Fix:** Add logging or use `firstWhereOrNull` instead of try/catch around `firstWhere`.
---
### 6. Audio Session Listeners Not Disposed
**File:** `/lib/services/audio/massiv_audio_handler.dart`
**Lines:** 45-73
Stream subscriptions created but never cancelled - causes memory leaks and zombie listeners.
**Fix:** Store subscriptions and cancel in dispose():
```dart
StreamSubscription? _interruptionSubscription;
_interruptionSubscription = session.interruptionEventStream.listen(...);
void dispose() {
_interruptionSubscription?.cancel();
}
```
---
## HIGH SEVERITY ISSUES
### 7. Connection State Subscription Not Stored
**File:** `/lib/providers/music_assistant_provider.dart:575-614`
The `connectionState.listen()` return value is not stored - causes listener accumulation on reconnect.
---
### 8. Player State Race Condition
**File:** `/lib/providers/music_assistant_provider.dart`
Timer polling (every 5s) and WebSocket events can race to update `_selectedPlayer` and `_currentTrack` without mutex protection.
---
### 9. Async Method Reentrancy
**File:** `/lib/providers/music_assistant_provider.dart:2713-2841`
`selectPlayer()` is `void async` with no guard against concurrent calls. Multiple rapid player switches can interleave.
---
### 10. Unbounded Search Cache
**File:** `/lib/services/cache_service.dart:31-32`
`_searchCache` map has no size limit or proactive TTL eviction. Grows indefinitely with user searches.
---
### 11. Static Memory Leak
**File:** `/lib/models/player.dart:96, 139-144`
`_playerCreationTimes` static map cleanup only removes 50 of 100+ entries, allowing unbounded growth.
---
### 12. No Database Indexes
**File:** `/lib/database/database.dart`
No secondary indexes defined. Query performance degrades as database grows.
---
### 13. No Incremental Sync
**File:** `/lib/services/sync_service.dart:150-165`
Full library fetch (up to 4000 items) on every sync instead of delta updates.
---
### 14. Offline Queue Not Atomic
**File:** `/lib/services/offline_action_queue.dart:80-108`
Queue persistence happens after all processing - crash during processing loses successful actions.
---
### 15. Auth Retry Without Deduplication
**File:** `/lib/services/sendspin_service.dart:189-209`
Multiple auth messages can be sent during rapid reconnection attempts.
---
### 16. Pending Requests Not Completed on Disconnect
**File:** `/lib/services/music_assistant_api.dart:2911`
`_pendingRequests.clear()` abandons Completers without completing them with errors.
---
### 17. HTTP Fallback for Local IPs
**File:** `/lib/services/auth/auth_manager.dart:51-58`
HTTPS failures silently fall back to HTTP on local networks - MITM vulnerability.
---
### 18. Certificate Validation Bypassed
**File:** `/lib/services/auth/auth_manager.dart:210-236`
HTTPS redirects explicitly refused for local IPs due to expected certificate failures.
---
## MEDIUM SEVERITY ISSUES
### 19. Double Opacity Bug
**File:** `/lib/widgets/player/player_controls.dart:62-63, 99-100`
```dart
// BUG: .withOpacity(0.5).withOpacity(expandedElementsOpacity) compounds opacity
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
```
---
### 20. Mixed Async Patterns
**Locations:** 13 instances
`.then()` chains mixed with `async/await` throughout the codebase.
---
### 21. Hardcoded Colors
**Locations:** 55 instances
`Color(0xFF604CEC)`, `Color(0xFF1a1a1a)` scattered across theme files and widgets.
---
### 22. View Modes as Strings
**Locations:** 80+ instances
`'grid2'`, `'grid3'`, `'list'` should be enums.
---
### 23. Fire-and-Forget Pause Commands
**File:** `/lib/providers/music_assistant_provider.dart:3875-3894`
Server pause command uses `unawaited()` - errors are silently lost.
---
### 24. Position Tracker Anchor Timeout Missing
**File:** `/lib/services/position_tracker.dart:24-26`
No timeout on stale anchor - interpolation can drift indefinitely.
---
### 25. PCM Buffer No Overflow Protection
**File:** `/lib/services/pcm_audio_player.dart:68, 236-247`
Audio buffer grows unbounded if data arrives faster than consumption.
---
### 26. No Client-Side Rate Limiting
**Location:** App-wide
No throttling on API requests during rapid user interactions.
---
### 27. Portrait-Only Lock
**File:** `/lib/main.dart:64-68`
No landscape orientation support.
---
### 28. Touch Targets Below 48dp
**Files:** player_controls.dart, queue_panel.dart, player_card.dart
Multiple buttons at 28x28 and 44x44 instead of minimum 48x48.
---
### 29. WCAG Contrast Not Validated
**File:** `/lib/theme/palette_helper.dart`
Uses 0.4 threshold instead of proper WCAG 4.5:1 contrast ratio calculation.
---
### 30. StreamControllers Not Closed
**File:** `/lib/services/hardware_volume_service.dart:18-19, 74-85`
`_volumeUpController` and `_volumeDownController` never closed in dispose().
---
## LOW SEVERITY ISSUES
### 31-34. Naming Inconsistencies
Mixed patterns: `_isLoading` vs `_isLoadingPlaylists`, various cache variable names.
### 35-38. Missing TODO Implementations
6 actionable TODOs identified (queue removal, audiobook progress sync, playlist modification).
### 39-42. Dead/Unused Code
- `@Deprecated` methods still implemented
- `library_stats.dart` appears unused
- `animation_debugger.dart` disabled by default
### 43-44. Minor Documentation Gaps
Missing method documentation, no architecture diagram.
---
## Recommended Remediation Roadmap
### Sprint 1 (Week 1-2): Security & Critical Fixes
1. Migrate credentials to `flutter_secure_storage`
2. Add logging to all silent catch blocks
3. Store connection state subscription
4. Fix audio session listener disposal
5. Add basic test infrastructure (10% coverage target)
### Sprint 2 (Week 3-4): Memory & Race Conditions
6. Add mutex to player state updates
7. Add reentrancy guard to `selectPlayer()`
8. Implement bounded caches with LRU eviction
9. Fix static memory leak in Player model
10. Store and cancel all stream subscriptions
### Sprint 3 (Week 5-6): Architecture Refactoring
11. Split `MusicAssistantProvider` into 5 smaller classes
12. Split `expandable_player.dart` into component widgets
13. Replace GlobalKey anti-patterns with Provider
14. Convert string constants to enums
15. Extract hardcoded colors to constants file
### Sprint 4 (Week 7-8): Accessibility & UI
16. Add Semantics to all interactive elements
17. Increase touch targets to 48dp minimum
18. Implement WCAG contrast validation
19. Fix double opacity bug
20. Add landscape orientation support
### Sprint 5 (Week 9-10): Performance & Polish
21. Add database indexes
22. Implement incremental sync
23. Add client-side rate limiting
24. Fix atomic offline queue processing
25. Achieve 30% test coverage target
---
## Files Requiring Most Attention
| File | LOC | Issues | Priority |
|------|-----|--------|----------|
| `music_assistant_provider.dart` | 4,469 | 12 | CRITICAL |
| `settings_service.dart` | 811 | 4 | CRITICAL |
| `expandable_player.dart` | 2,485 | 6 | HIGH |
| `music_assistant_api.dart` | 2,929 | 5 | HIGH |
| `cache_service.dart` | 370 | 4 | HIGH |
| `massiv_audio_handler.dart` | 296 | 2 | CRITICAL |
| `player.dart` | 422 | 2 | HIGH |
| `auth_manager.dart` | 300 | 3 | HIGH |
---
## Estimated Technical Debt
| Category | Effort (Dev-Days) |
|----------|-------------------|
| Security Fixes | 3-5 |
| Test Infrastructure | 10-15 |
| Architecture Refactoring | 15-20 |
| Memory Leak Fixes | 3-5 |
| Accessibility | 5-8 |
| UI/UX Polish | 3-5 |
| Performance | 5-8 |
| **Total** | **44-66 dev-days** |
---
## Conclusion
The Ensemble app has a solid foundation with well-designed patterns for caching, audio handling, and multi-device synchronization. However, **critical security vulnerabilities** (plaintext credentials), **zero test coverage**, and a **4,469-line god class** represent significant risks that must be addressed before any production deployment.
The most urgent priorities are:
1. **Secure credential storage** - immediate security risk
2. **Add test infrastructure** - enables safe refactoring
3. **Split the god class** - enables maintainability
4. **Add accessibility** - legal/compliance requirement
With focused effort over 8-10 weeks, the codebase can be brought to production-quality standards.
---
*Audit conducted using 6 parallel specialized sub-agents analyzing security, memory management, race conditions, architecture, UI/UX, and audio/networking.*
+119
View File
@@ -0,0 +1,119 @@
# Ensemble Codebase Audit Prompt
Run this prompt with Claude Code to perform a comprehensive audit of the Ensemble Flutter app.
---
## Prompt
You are performing a comprehensive code audit on the Ensemble Flutter app. This is a music/audiobook/podcast player app with 93+ Dart files.
**CRITICAL RULES:**
1. DO NOT remove or break any existing functionality
2. All changes must be on the current branch: `audit/code-quality-review`
3. Run `flutter analyze` after each set of changes to verify no regressions
4. Commit changes in logical, reviewable chunks
**AUDIT PHASES:**
### Phase 1: Code Quality Analysis (Read-Only)
First, analyze the codebase without making changes. Create a report covering:
1. **Large File Analysis** - Review these files for refactoring opportunities:
- `lib/screens/new_library_screen.dart` (122KB)
- `lib/screens/search_screen.dart` (129KB)
- `lib/widgets/expandable_player.dart` (107KB)
- `lib/screens/album_details_screen.dart` (48KB)
- `lib/screens/artist_details_screen.dart` (45KB)
2. **Pattern Inconsistencies** - Look for:
- Inconsistent naming conventions (camelCase vs snake_case)
- Mixed widget composition patterns
- Inconsistent error handling approaches
- Duplicate code across screens/widgets
3. **Performance Red Flags** - Identify:
- Unnecessary rebuilds (missing const constructors)
- Heavy computations in build methods
- Missing list item caching (ListView.builder vs ListView)
- Inefficient state management patterns
4. **Scroll Performance Issues** - Specifically audit:
- ScrollController usage and disposal
- Sliver implementations
- AnimatedBuilder/AnimatedWidget usage
- Image loading in scrollable lists
- Any jank-inducing patterns
### Phase 2: Quick Wins (Low Risk, High Impact)
Apply these fixes first:
1. Add missing `const` constructors throughout
2. Fix any `flutter analyze` warnings
3. Remove dead code and unused imports
4. Standardize formatting with `dart format`
### Phase 3: Scroll Animation Optimization
Focus on smoother scrolling:
1. **Image Optimization**
- Ensure CachedNetworkImage is used consistently
- Add appropriate cacheWidth/cacheHeight
- Implement proper placeholder/error widgets
2. **List Performance**
- Convert any ListView to ListView.builder where appropriate
- Add itemExtent where possible for fixed-height items
- Implement AutomaticKeepAliveClientMixin where needed
- Consider RepaintBoundary for complex list items
3. **Animation Smoothing**
- Use `Curves.easeOutCubic` or similar for scroll-linked animations
- Ensure animations run at 60fps (avoid heavy computations during animation)
- Review CustomScrollView/Sliver implementations
### Phase 4: Code Consistency Improvements
1. **Widget Extraction**
- Extract repeated UI patterns into reusable widgets
- Ensure extracted widgets follow existing naming patterns
2. **State Management**
- Verify Provider usage is consistent
- Check for proper disposal of controllers/streams
3. **Error Handling**
- Standardize try/catch patterns
- Ensure consistent loading/error state UI
### Phase 5: Verification
After all changes:
1. Run `flutter analyze` - must pass with 0 issues
2. Run `flutter test` if tests exist
3. Create a summary of all changes made
4. List any recommendations that were NOT implemented (too risky)
---
## Execution Instructions
```bash
# Start the audit
cd /home/home-server/Ensemble
git checkout audit/code-quality-review
# After each phase, commit your changes
git add -A && git commit -m "Audit Phase X: [description]"
# Verify no regressions
flutter analyze
```
---
## Expected Deliverables
1. `AUDIT_REPORT.md` - Findings from Phase 1
2. Multiple commits with clear messages for each phase
3. `AUDIT_SUMMARY.md` - Final summary of all changes and remaining recommendations
+257
View File
@@ -0,0 +1,257 @@
# Ensemble Codebase Audit Report
**Date**: 2026-01-14
**Scope**: Full codebase audit (104 Dart files, 18 screens)
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
---
## Executive Summary
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:
| Severity | Count | Key Themes |
|----------|-------|------------|
| **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
| **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
| **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
| **Low** | 15+ | Minor polish items |
**Top 3 Priorities:**
1. **Split `MusicAssistantProvider` (5,443 lines)** - God object handling everything
2. **Split `new_library_screen.dart` (4,209 lines)** - 10 scroll controllers, 15+ view modes
3. **Fix startup performance** - Sequential blocking ops could save 1-8 seconds
---
## Critical Issues
### 1. God Object: MusicAssistantProvider (5,443 lines)
**File**: `lib/providers/music_assistant_provider.dart`
- 54 `notifyListeners()` calls
- 30+ state variables
- 100+ public methods
- Handles: connection, auth, players, library, search, caching, local playback, Sendspin
- **Fix**: Split into ConnectionManager, PlayerStateManager, LibraryStateManager, SearchStateManager
### 2. God Object: NewLibraryScreen (4,209 lines)
**File**: `lib/screens/new_library_screen.dart`
- 10 scroll controllers
- 15+ view mode states
- 15+ sort order states
- Handles: Music, Books, Podcasts, Radio tabs
- **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
### 3. Sequential Blocking Startup (500-1500ms wasted)
**File**: `lib/main.dart:33-66`
```dart
await DatabaseService.instance.initialize();
await ProfileService.instance.migrateFromOwnerName();
await SettingsService.migrateToSecureStorage();
await SyncService.instance.loadFromCache();
audioHandler = await AudioService.init(...);
```
- **Fix**: Parallelize with `Future.wait()`, defer AudioService to post-first-frame
### 4. Design Tokens Exist But Unused
**File**: `lib/theme/design_tokens.dart`
- `Spacing.*` used in only ~6 of 34+ widget files
- `Radii.*` used in only ~5 files
- `IconSizes.*` used in only ~4 files
- **Fix**: Systematic replacement of hardcoded values
### 5. Touch Targets Too Small (32x32)
**File**: `lib/screens/new_library_screen.dart:1805-1867`
- Sort, favorites, view mode buttons are 32x32
- Material Design minimum: 48x48
- **Fix**: Increase to 48x48 or add padding for hit area
### 6. No Batch Database Inserts
**File**: `lib/services/sync_service.dart:212-273`
- 1000 albums = 1000 separate DB transactions
- **Fix**: Wrap in single transaction or use Drift batch insert
---
## High Severity Issues
### Performance
| Issue | File | Impact |
|-------|------|--------|
| `context.watch()` in nested tabs | new_library_screen.dart:2960 | All tabs rebuild on any provider change |
| Consumer wrapping 800-line player | expandable_player.dart:1234 | Full rebuild on every state change |
| Inline sorting during build | new_library_screen.dart:3647 | 5-20ms for 1000+ albums |
| Multiple setState for author images | new_library_screen.dart:1017 | 20+ rebuilds as images load |
| SecureStorage migration every startup | secure_storage_service.dart:103 | 100-300ms wasted |
### Architecture
| Issue | File | Impact |
|-------|------|--------|
| State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
| 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
| 60+ duplicate getter/setter pairs | settings_service.dart:500-1000 | Code bloat |
| Global singleton NavigationProvider | navigation_provider.dart:40 | Untestable |
| CacheService duplicated in providers | Multiple | Memory waste, inconsistency |
### UI/UX
| Issue | File | Impact |
|-------|------|--------|
| Hardcoded bottom spacing (140, 164, 100) | Multiple detail screens | Content obscured by player |
| Inconsistent button sizes (44 vs 50) | Multiple screens | Visual disharmony |
| No debounce on card taps | album_card.dart:118 | Double navigation possible |
| Missing PopScope on AudiobookSeriesScreen | audiobook_series_screen.dart:276 | Adaptive colors not cleared |
### Error Handling
| Issue | File | Impact |
|-------|------|--------|
| No Flutter error boundaries | main.dart | Red error screen on build failure |
| API returns empty list on error | music_assistant_api.dart | Can't distinguish error from empty |
---
## Medium Severity Issues
### 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
### UI Consistency
- Border radius varies: 8, 12, 20, 22, 25 for same element types
- `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
- Hardcoded colors for status (Colors.red, Colors.green)
- Inconsistent icon sizes across screens
### 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
---
## Quick Wins (High Impact, Low Effort)
| # | Task | File(s) | Effort |
|---|------|---------|--------|
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
| 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
| 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
| 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
| 5 | Add Flutter error boundary | main.dart | 30 min |
| 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
| 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
---
## Refactoring Roadmap
### Phase 1: Quick Wins (1-2 days)
- Fix touch targets
- Fix bottom spacing
- Add navigation debounce
- Add error boundaries
- Cache SharedPreferences singleton
### Phase 2: Performance (3-5 days)
- Parallelize startup operations
- Replace `context.watch()` with `context.select()`
- Batch database inserts
- Add SecureStorage migration skip flag
- Fix over-notification in provider
### Phase 3: Design System (1 week)
- Systematic design token adoption
- Standardize button sizes (44 vs 50)
- Standardize border radius values
- Create StatusColors semantic tokens
- Replace all magic numbers
### Phase 4: Architecture (2-3 weeks)
- ~~Split MusicAssistantProvider into focused managers~~ **DEFERRED** - MusicAssistantProvider has evolved with Sendspin, Cast-Sendspin ID mapping, position tracker integration. Splitting would require ~1600 lines of sync work. Code is working and tested.
- ~~Split new_library_screen into tab widgets~~ **DEFERRED** - Tabs have tight state coupling (15+ view modes, 15+ sort orders, 10 scroll controllers). Extraction would require InheritedWidget/callbacks for all shared state. Risk outweighs benefit.
- ✅ Extract generic helpers for SettingsService - Reduced from 1,031 to 874 lines (~15%)
- ✅ Remove/integrate unused providers - Deleted PlayerProvider (964 lines), LibraryProvider, ConnectionProvider (651 lines total)
- ~~Split music_assistant_api.dart by domain~~ **DEFERRED** - All methods share WebSocket connection and auth state. Clean extraction would require connection manager abstraction.
---
## Positive Findings
The codebase already demonstrates many good practices:
- **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
- **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
- **Theme system**: Material You support, adaptive colors from album art
- **Offline support**: Cache-first loading, graceful degradation
- **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
---
## Files by Priority
| Priority | File | Lines | Key Issues |
|----------|------|-------|------------|
| 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
| 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
| 3 | main.dart | 549 | Startup blocking, error boundaries |
| 4 | music_assistant_api.dart | 3,434 | Silent failures, needs splitting |
| 5 | settings_service.dart | 1,019 | Code duplication, no caching |
| 6 | expandable_player.dart | ~800 | Wide Consumer scope |
| 7 | global_player_overlay.dart | ~500 | BottomSpacing source, Consumer scope |
| 8 | All detail screens | Various | Hardcoded bottom spacing |
| 9 | All card widgets | Various | Navigation debounce, design tokens |
---
## Audit Methodology
This audit was conducted using 9 parallel sub-agents analyzing:
**UI Continuity (3 agents)**
1. Navigation & Flow - back button, Hero animations, tab preservation
2. Visual Consistency - design tokens, spacing, typography
3. Component Placement - touch targets, bottom spacing, modals
**Performance (3 agents)**
4. Rendering Performance - ListView.builder, const, rebuilds
5. Data & Caching - streams, invalidation, memory bounds
6. Startup & Load Time - blocking operations, initialization order
**Logic/Best Practices (3 agents)**
7. State Management - Provider patterns, notification frequency
8. Error Handling - try-catch, boundaries, resilience
9. Code Quality - file size, duplication, SOLID principles
---
## Conclusion
The Ensemble app is well-architected at the macro level but has accumulated technical debt in its largest files. The most impactful improvements would be:
1. **Splitting the two 4,000+ line files** into focused components
2. **Adopting design tokens systematically** for visual consistency
3. **Optimizing startup sequence** for faster app launch
4. **Using granular state management** (select vs watch) for better performance
The quick wins section provides immediate value with minimal risk, while the roadmap offers a path to sustainable improvement.
+57
View File
@@ -0,0 +1,57 @@
# Audit Summary
**Branch:** `audit/code-quality-review`
**Date:** January 4, 2026
## Changes Applied
### Phase 1: Analysis Complete
- Created `AUDIT_REPORT.md` with comprehensive findings
- 4 parallel subagents analyzed const constructors, scroll performance, code duplication, and dead code
### Phase 2: Deprecated API Fixes (18 files)
- Replaced all `colorScheme.background` with `colorScheme.surface` (Material 3 compliance)
**Files modified:**
- lib/screens/library_artists_screen.dart
- lib/screens/library_albums_screen.dart
- lib/screens/library_playlists_screen.dart
- lib/screens/library_tracks_screen.dart
- lib/screens/artist_details_screen.dart
- lib/screens/new_home_screen.dart
- lib/screens/login_screen.dart
- lib/screens/settings_screen.dart
- lib/screens/album_details_screen.dart
- lib/screens/search_screen.dart
- lib/screens/new_library_screen.dart
### Phase 3: Scroll Performance Optimization
- Added `memCacheWidth` and `memCacheHeight` to CachedNetworkImage instances
**lib/screens/new_library_screen.dart:**
- Line 1400-1401: Author list tile image (128x128 cache)
- Line 1487-1488: Author grid card image (256x256 cache)
- Line 1577-1578: Audiobook list tile cover (256x256 cache)
- Line 1749-1750: Audiobook grid cover (512x512 cache)
- Line 1970-1971: Series list tile cover (256x256 cache)
- Line 2986-2987: Album list tile cover (256x256 cache)
## Recommendations NOT Implemented (Low Priority)
These were identified but NOT changed to preserve functionality:
1. **View mode cycling refactor** - 8 similar functions could be consolidated
2. **Color constant extraction** - Duplicated colors in 3 files
3. **EdgeInsets.zero const** - 64 instances (build-time only, minimal impact)
4. **BorderRadius.circular const** - 40+ instances
## Verification
Changes should be verified by:
1. GitHub Actions build (triggered automatically)
2. Manual UI testing of scroll smoothness
3. Verify no visual regressions in dark/light themes
## Build Trigger
GitHub Actions workflow triggered on branch `audit/code-quality-review`
+66 -25
View File
@@ -20,44 +20,63 @@ This application was built with AI-assisted development using **Claude Code** an
## Features
### Local Playback
- **Stream to Your Phone** - Play music from your Music Assistant library directly on your mobile device
- **Stream to Your Phone** - Play music from your Music Assistant library directly on your mobile device via Sendspin protocol
- **Background Playback** - Music continues playing when the app is minimized
- **Media Notifications** - Control playback from your notification shade with album art display
- **Instant Response** - Pause/resume in ~300ms
### Remote Control
- **Multi-Player Support** - Control any speaker or device connected to Music Assistant
- **Device Selector** - Quickly switch between your phone and other players
- **Device Selector** - Swipe down on mini player to reveal all your devices
- **Multi-Room Grouping** - Long-press any player to sync it with the current player
- **Full Playback Controls** - Play, pause, skip, seek, and adjust volume
- **Queue Management** - View and manage the playback queue
- **Volume Precision Mode** - Hold the volume slider for fine-grained control with haptic feedback
- **Power Control** - Turn players on/off directly from the mini player
### Queue Management
- **View & Manage Queue** - See upcoming tracks in the playback queue
- **Drag to Reorder** - Instant drag handles for reordering tracks
- **Swipe to Delete** - Remove tracks with a simple swipe gesture
### Home Screen
- **Customizable Rows** - Toggle Recently Played, Discover Artists, and Discover Albums
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, and Tracks
- **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, Tracks, Playlists, and Radio Stations
- **Adaptive Layout** - Rows scale properly for different screen sizes and aspect ratios
- **Pull to Refresh** - Refresh content with a simple pull gesture
### Library Browsing
- **Browse Your Collection** - Artists, albums, playlists, and tracks from all your music sources
### Library
- **Music** - Browse artists, albums, playlists, and tracks from all your music sources
- **Radio Stations** - Browse and play radio stations with list or grid view
- **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates
- **Audiobooks** - Browse by title, series, or author with progress tracking
- **Favorites Filter** - Toggle to show only your favorite items
- **Album Details** - View track listings with artwork
- **Artist Details** - View artist albums and top tracks
- **Playlist Support** - Browse and play your playlists
- **Search** - Find music across your entire library
- **Letter Scrollbar** - Fast navigation through long lists
### Search
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
- **Fuzzy Matching** - Typo-tolerant search (e.g., "beetles" finds "Beatles")
- **Smart Scoring** - Results ranked by relevance with colored type indicators
- **Search History** - Quickly access your recent searches
- **Quick Actions** - Long-press any result to add to queue or play next
### Audiobooks
- **Audiobooks Tab** - Browse your audiobook library with grid/list view options
- **Series Support** - View audiobooks organized by series with collage cover art
- **Author Browsing** - Browse audiobooks by author
- **Chapter Navigation** - Jump between chapters with timestamp display
- **Progress Tracking** - Track your listening progress across sessions
- **Continue Listening** - Pick up where you left off
- **Mark as Finished/Unplayed** - Manage your reading progress
- **Series Support** - View audiobooks organized by series with collage cover art
### Podcasts
- **Episode Browser** - View full episode list with artwork and descriptions
- **Skip Controls** - Skip forward/backward during playback
- **High-Resolution Artwork** - Fetched via iTunes for best quality
### Smart Features
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
- **Auto-Reconnect** - Automatically reconnects when connection is lost
- **Offline Browsing** - Browse your cached library even when disconnected
- **Hero Animations** - Smooth transitions between screens
- **Welcome Screen** - Guided onboarding for first-time users
### Theming
- **Material You** - Dynamic theming based on your device's wallpaper
@@ -67,14 +86,16 @@ This application was built with AI-assisted development using **Claude Code** an
## Screenshots
<div align="center">
<img src="assets/screenshots/1.png?v=2" alt="Connection Screen" width="150">
<img src="assets/screenshots/2.png?v=2" alt="Home Screen" width="150">
<img src="assets/screenshots/3.png?v=2" alt="Album Details" width="150">
<img src="assets/screenshots/4.png?v=2" alt="Now Playing" width="150">
<img src="assets/screenshots/5.png?v=2" alt="Queue" width="150">
<img src="assets/screenshots/6.png?v=2" alt="Settings" width="150">
<img src="assets/screenshots/7.png?v=2" alt="Audiobooks" width="150">
<img src="assets/screenshots/8.png?v=2" alt="Audiobook Player" width="150">
<img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150">
<img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150">
<img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150">
<img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150">
<img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150">
<img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150">
<img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" width="150">
<img src="assets/screenshots/8.png?v=3" alt="Screenshot 8" width="150">
<img src="assets/screenshots/9.png?v=3" alt="Screenshot 9" width="150">
<img src="assets/screenshots/10.png?v=3" alt="Screenshot 10" width="150">
</div>
## Download
@@ -84,9 +105,29 @@ Download the latest release from the [Releases page](https://github.com/CollotsS
## Setup
1. Launch the app
2. Enter your Music Assistant server URL (e.g., `music.example.com` or `192.168.1.100`)
2. Enter your Music Assistant server URL
3. Connect to your server
4. Start playing! Music plays on your phone by default, or tap the device icon to choose a different player.
4. Start playing! Music plays on your phone by default, or swipe down on the mini player to choose a different player.
### Finding Your Server URL
**Important:** You need the **Music Assistant** URL, not your Home Assistant URL.
To find the correct URL:
1. Open Music Assistant web UI
2. Go to **Settings** > **About**
3. Look for **Base URL** (e.g., `http://192.168.1.100:8095`)
### Home Assistant Add-on Users
If you run Music Assistant as a Home Assistant add-on:
- Use the IP address of your Home Assistant server
- Enter `8095` in the port field
- Do **not** use your Home Assistant URL or ingress URL
### Remote Access
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
## Authentication
@@ -103,7 +144,7 @@ Ensemble supports multiple authentication methods:
## Requirements
- Music Assistant server (v2.7.0 beta 20 or later)
- Music Assistant server (v2.7.0 beta 20 or later recommended)
- Network connectivity to your Music Assistant server
- Android device (Android 5.0+)
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
+52
View File
@@ -0,0 +1,52 @@
# Search Improvements
Branch: `feature/search-improvements`
## Completed
- [x] Add playlists and audiobooks to search results
- Parse from MA API response
- Filter chips for each type
- Tile builders with navigation to detail screens
- [x] Unified Relevance-Based "All" Results
- Scoring algorithm implemented:
- Exact match: 100 points
- Starts with query: 80 points
- Contains query (word boundary): 60 points
- Contains query (anywhere): 40 points
- Fuzzy match: 20 points
- Bonus for library items (albums): +10 points
- Bonus for favorites: +5 points
- Secondary field matching (artist name in albums/tracks)
- Merged all results into single sorted list
- Type indicators in subtitle (e.g., "Artist • Album")
- [x] Smart Cross-Referencing
- Extracts unique artists from matched tracks/albums
- Filters to artists whose name contains query words (min 3 chars)
- Adds cross-referenced artists with lower relevance score (25)
- Avoids duplicates with direct search results
- [x] Past Searches Row
- Added SearchHistory table to database (schema v4)
- Saves successful searches (deduplicated, max 10)
- Displays as ActionChips in empty state
- Clicking chip performs that search
## Remaining Ideas (Lower Priority)
- **Library-only toggle:** Add switch to filter search to library items only (MA API supports this)
- **Voice search:** Add microphone button if platform supports speech recognition
- **Search suggestions:** Typeahead from library as user types
- **Quick actions:** Long-press result for play/queue options without navigating
- **Clear history in settings:** Add option to clear search history
## Files Modified
| File | Changes |
|------|---------|
| `lib/database/database.dart` | Added SearchHistory table, schema v4 |
| `lib/services/database_service.dart` | Added search history methods |
| `lib/screens/search_screen.dart` | Relevance scoring, cross-refs, history UI |
| `lib/l10n/app_en.arb` | Added localization strings |
+1
View File
@@ -11,6 +11,7 @@
android:label="Ensemble"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:appCategory="audio"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

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

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

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

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+18
View File
@@ -25,4 +25,22 @@ class HeroTags {
/// Audiobook author image hero tag prefix
static const String authorImage = 'author_image_';
/// Podcast cover hero tag prefix
static const String podcastCover = 'podcast_cover_';
/// Podcast title hero tag prefix
static const String podcastTitle = 'podcast_title_';
/// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_';
/// Playlist title hero tag prefix
static const String playlistTitle = 'playlist_title_';
/// Radio station cover hero tag prefix
static const String radioCover = 'radio_cover_';
/// Radio station title hero tag prefix
static const String radioTitle = 'radio_title_';
}
+3
View File
@@ -31,6 +31,9 @@ class Timings {
/// Search debounce delay
static const Duration searchDebounce = Duration(milliseconds: 500);
/// Navigation debounce to prevent double-tap navigation
static const Duration navigationDebounce = Duration(milliseconds: 500);
/// Delay after track change before updating state
static const Duration trackChangeDelay = Duration(milliseconds: 500);
}
+326 -3
View File
@@ -25,6 +25,8 @@ class Profiles extends Table {
}
/// Recently played items - scoped by profile
@TableIndex(name: 'idx_recently_played_profile', columns: {#profileUsername})
@TableIndex(name: 'idx_recently_played_profile_played', columns: {#profileUsername, #playedAt})
class RecentlyPlayed extends Table {
/// Auto-incrementing ID
IntColumn get id => integer().autoIncrement()();
@@ -55,6 +57,8 @@ class RecentlyPlayed extends Table {
}
/// Cached library items for fast startup
@TableIndex(name: 'idx_library_cache_type', columns: {#itemType})
@TableIndex(name: 'idx_library_cache_type_deleted', columns: {#itemType, #isDeleted})
class LibraryCache extends Table {
/// Composite key: provider + item_id
TextColumn get cacheKey => text()();
@@ -93,12 +97,101 @@ class SyncMetadata extends Table {
Set<Column> get primaryKey => {syncType};
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata])
/// Persisted playback state - survives app restarts and disconnects
class PlaybackState extends Table {
/// Always 'current' - single row table
TextColumn get id => text().withDefault(const Constant('current'))();
/// Selected player ID
TextColumn get playerId => text().nullable()();
/// Selected player name (for display if player unavailable)
TextColumn get playerName => text().nullable()();
/// Current track as JSON
TextColumn get currentTrackJson => text().nullable()();
/// Current position in seconds
RealColumn get positionSeconds => real().withDefault(const Constant(0.0))();
/// Whether playback was active
BoolColumn get isPlaying => boolean().withDefault(const Constant(false))();
/// When this state was saved
DateTimeColumn get savedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
/// Cached player list - for instant display on app resume
class CachedPlayers extends Table {
/// Player ID from Music Assistant
TextColumn get playerId => text()();
/// Player data as JSON
TextColumn get playerJson => text()();
/// Current track for this player as JSON (for mini player display)
TextColumn get currentTrackJson => text().nullable()();
/// When this was last updated
DateTimeColumn get lastUpdated => dateTime()();
@override
Set<Column> get primaryKey => {playerId};
}
/// Cached queue items for selected player
@TableIndex(name: 'idx_cached_queue_player', columns: {#playerId})
@TableIndex(name: 'idx_cached_queue_player_position', columns: {#playerId, #position})
class CachedQueue extends Table {
/// Auto-incrementing ID for ordering
IntColumn get id => integer().autoIncrement()();
/// Player ID this queue belongs to
TextColumn get playerId => text()();
/// Queue item as JSON
TextColumn get itemJson => text()();
/// Position in queue
IntColumn get position => integer()();
}
/// Cached home row data for instant display on startup
class HomeRowCache extends Table {
/// Row type: 'recent_albums', 'discover_artists', 'discover_albums'
TextColumn get rowType => text()();
/// Serialized list of items as JSON array
TextColumn get itemsJson => text()();
/// When this was last updated
DateTimeColumn get lastUpdated => dateTime()();
@override
Set<Column> get primaryKey => {rowType};
}
/// Search history for quick access to recent searches
class SearchHistory extends Table {
/// Auto-incrementing ID
IntColumn get id => integer().autoIncrement()();
/// The search query
TextColumn get query => text()();
/// When the search was performed
DateTimeColumn get searchedAt => dateTime()();
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
int get schemaVersion => 5;
@override
MigrationStrategy get migration {
@@ -107,7 +200,32 @@ class AppDatabase extends _$AppDatabase {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
// Future migrations will go here
// Migration from v1 to v2: Add playback state, cached players, cached queue tables
if (from < 2) {
await m.createTable(playbackState);
await m.createTable(cachedPlayers);
await m.createTable(cachedQueue);
}
// Migration from v2 to v3: Add home row cache table
if (from < 3) {
await m.createTable(homeRowCache);
}
// Migration from v3 to v4: Add search history table
if (from < 4) {
await m.createTable(searchHistory);
}
// Migration from v4 to v5: Add indexes for query performance
if (from < 5) {
// RecentlyPlayed indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_recently_played_profile ON recently_played (profile_username)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_recently_played_profile_played ON recently_played (profile_username, played_at)');
// LibraryCache indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_library_cache_type ON library_cache (item_type)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_library_cache_type_deleted ON library_cache (item_type, is_deleted)');
// CachedQueue indexes
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)');
}
},
);
}
@@ -327,4 +445,209 @@ class AppDatabase extends _$AppDatabase {
if (lastSync == null) return true;
return DateTime.now().difference(lastSync) > maxAge;
}
// ============================================
// Playback State Operations
// ============================================
/// Save current playback state
Future<void> savePlaybackState({
String? playerId,
String? playerName,
String? currentTrackJson,
double positionSeconds = 0.0,
bool isPlaying = false,
}) async {
await into(playbackState).insertOnConflictUpdate(PlaybackStateCompanion.insert(
id: const Value('current'),
playerId: Value(playerId),
playerName: Value(playerName),
currentTrackJson: Value(currentTrackJson),
positionSeconds: Value(positionSeconds),
isPlaying: Value(isPlaying),
savedAt: DateTime.now(),
));
}
/// Get saved playback state
Future<PlaybackStateData?> getPlaybackState() async {
return (select(playbackState)..where((p) => p.id.equals('current')))
.getSingleOrNull();
}
/// Clear playback state
Future<void> clearPlaybackState() async {
await delete(playbackState).go();
}
// ============================================
// Cached Players Operations
// ============================================
/// Cache a player with its current track
Future<void> cachePlayer({
required String playerId,
required String playerJson,
String? currentTrackJson,
}) async {
await into(cachedPlayers).insertOnConflictUpdate(CachedPlayersCompanion.insert(
playerId: playerId,
playerJson: playerJson,
currentTrackJson: Value(currentTrackJson),
lastUpdated: DateTime.now(),
));
}
/// Cache multiple players at once
Future<void> cachePlayers(List<Map<String, dynamic>> players) async {
await transaction(() async {
for (final player in players) {
await into(cachedPlayers).insertOnConflictUpdate(CachedPlayersCompanion.insert(
playerId: player['playerId'] as String,
playerJson: player['playerJson'] as String,
currentTrackJson: Value(player['currentTrackJson'] as String?),
lastUpdated: DateTime.now(),
));
}
});
}
/// Get all cached players
Future<List<CachedPlayer>> getCachedPlayers() async {
return (select(cachedPlayers)
..orderBy([(p) => OrderingTerm.desc(p.lastUpdated)]))
.get();
}
/// Get a specific cached player
Future<CachedPlayer?> getCachedPlayer(String playerId) async {
return (select(cachedPlayers)..where((p) => p.playerId.equals(playerId)))
.getSingleOrNull();
}
/// Update cached track for a player
Future<void> updateCachedPlayerTrack(String playerId, String? trackJson) async {
await (update(cachedPlayers)..where((p) => p.playerId.equals(playerId)))
.write(CachedPlayersCompanion(
currentTrackJson: Value(trackJson),
lastUpdated: Value(DateTime.now()),
));
}
/// Clear all cached players
Future<void> clearCachedPlayers() async {
await delete(cachedPlayers).go();
}
// ============================================
// Cached Queue Operations
// ============================================
/// Save queue for a player
Future<void> saveQueue(String playerId, List<String> itemJsonList) async {
await transaction(() async {
// Clear existing queue for this player
await (delete(cachedQueue)..where((q) => q.playerId.equals(playerId))).go();
// Insert new queue items
for (var i = 0; i < itemJsonList.length; i++) {
await into(cachedQueue).insert(CachedQueueCompanion.insert(
playerId: playerId,
itemJson: itemJsonList[i],
position: i,
));
}
});
}
/// Get cached queue for a player
Future<List<CachedQueueData>> getCachedQueue(String playerId) async {
return (select(cachedQueue)
..where((q) => q.playerId.equals(playerId))
..orderBy([(q) => OrderingTerm.asc(q.position)]))
.get();
}
/// Clear queue for a player
Future<void> clearCachedQueue(String playerId) async {
await (delete(cachedQueue)..where((q) => q.playerId.equals(playerId))).go();
}
/// Clear all cached queues
Future<void> clearAllCachedQueues() async {
await delete(cachedQueue).go();
}
// ============================================
// Home Row Cache Operations
// ============================================
/// Save home row data
Future<void> saveHomeRowCache(String rowType, String itemsJson) async {
await into(homeRowCache).insertOnConflictUpdate(
HomeRowCacheCompanion(
rowType: Value(rowType),
itemsJson: Value(itemsJson),
lastUpdated: Value(DateTime.now()),
),
);
}
/// Get cached home row data
Future<HomeRowCacheData?> getHomeRowCache(String rowType) async {
return (select(homeRowCache)..where((h) => h.rowType.equals(rowType)))
.getSingleOrNull();
}
/// Clear all home row cache
Future<void> clearHomeRowCache() async {
await delete(homeRowCache).go();
}
// ============================================
// Search History Operations
// ============================================
/// Save a search query to history (deduplicates and limits to 10)
Future<void> saveSearchQuery(String query) async {
final trimmedQuery = query.trim();
if (trimmedQuery.isEmpty) return;
await transaction(() async {
// Delete existing entry with same query (case-insensitive)
await (delete(searchHistory)
..where((s) => s.query.lower().equals(trimmedQuery.toLowerCase())))
.go();
// Insert the new search
await into(searchHistory).insert(SearchHistoryCompanion.insert(
query: trimmedQuery,
searchedAt: DateTime.now(),
));
// Keep only the 10 most recent searches
final allSearches = await (select(searchHistory)
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)]))
.get();
if (allSearches.length > 10) {
final toDelete = allSearches.skip(10).map((s) => s.id).toList();
await (delete(searchHistory)..where((s) => s.id.isIn(toDelete))).go();
}
});
}
/// Get recent search queries (up to 10, most recent first)
Future<List<String>> getRecentSearches() async {
final searches = await (select(searchHistory)
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)])
..limit(10))
.get();
return searches.map((s) => s.query).toList();
}
/// Clear all search history
Future<void> clearSearchHistory() async {
await delete(searchHistory).go();
}
}
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -68,6 +68,12 @@
"tracks": "Songs",
"playlists": "Wiedergabelisten",
"audiobooks": "Hörbücher",
"music": "Musik",
"radio": "Radio",
"stations": "Sender",
"selectLibrary": "Bibliothek auswählen",
"noRadioStations": "Keine Radiosender",
"addRadioStationsHint": "Füge Radiosender in Music Assistant hinzu",
"searchFailed": "Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.",
"queue": "Warteschlange",
@@ -126,6 +132,7 @@
"playing": "Spiele {name}",
"playingTrack": "Spiele Song",
"nowPlaying": "Wird gespielt",
"markedAsFinished": "{name} als beendet markiert",
"failedToMarkFinished": "Konnte nicht als beendet markieren: {error}",
@@ -196,6 +203,7 @@
"english": "Englisch",
"german": "Deutsch",
"spanish": "Spanisch",
"french": "Französisch",
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
"sortAlphabetically": "Alphabetisch sortieren",
@@ -269,8 +277,23 @@
"audiobookSingular": "Hörbuch",
"trackCount": "{count} {count, plural, =1{Song} other{Songs}}",
"materialYouDescription": "Systemfarben verwenden (Android 12+)",
"accentColor": "Akzentfarbe",
"players": "Geräte",
"preferLocalPlayer": "Lokales Gerät bevorzugen",
"preferLocalPlayerDescription": "Dieses Gerät immer zuerst auswählen, wenn verfügbar",
"smartSortPlayers": "Intelligente Sortierung",
"smartSortPlayersDescription": "Nach Status sortieren (spielt, an, aus) statt alphabetisch",
"playerStateUnavailable": "Nicht verfügbar",
"playerStateOff": "Aus",
"playerStateIdle": "Inaktiv",
"playerSelected": "Ausgewählt"
"playerStateExternalSource": "Externe Quelle",
"playerSelected": "Ausgewählt",
"actionQueuedForSync": "Wird synchronisiert wenn online",
"pendingOfflineActions": "{count} ausstehende Synchronisierung",
"hintsAndTips": "Tipps & Hinweise",
"showHints": "Hinweise anzeigen",
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
"holdToSync": "Gedrückt halten zum Sync",
"swipeToAdjustVolume": "Wischen für Lautstärke"
}
+271 -1
View File
@@ -251,6 +251,21 @@
"description": "Home screen section title"
},
"favoritePlaylists": "Favorite Playlists",
"@favoritePlaylists": {
"description": "Home screen section title"
},
"favoriteRadioStations": "Favorite Radio Stations",
"@favoriteRadioStations": {
"description": "Home screen section title"
},
"favoritePodcasts": "Favorite Podcasts",
"@favoritePodcasts": {
"description": "Home screen section title"
},
"searchMusic": "Search music...",
"@searchMusic": {
"description": "Search placeholder text"
@@ -261,6 +276,26 @@
"description": "Search hint text"
},
"recentSearches": "Recent Searches",
"@recentSearches": {
"description": "Label for recent search history section"
},
"clearSearchHistory": "Clear Search History",
"@clearSearchHistory": {
"description": "Button to clear search history"
},
"searchHistoryCleared": "Search history cleared",
"@searchHistoryCleared": {
"description": "Confirmation message after clearing search history"
},
"libraryOnly": "Library only",
"@libraryOnly": {
"description": "Toggle to search only in library"
},
"retry": "Retry",
"@retry": {
"description": "Retry button label"
@@ -296,6 +331,36 @@
"description": "Audiobooks category"
},
"music": "Music",
"@music": {
"description": "Music category"
},
"radio": "Radio",
"@radio": {
"description": "Radio category"
},
"stations": "Stations",
"@stations": {
"description": "Radio stations subcategory"
},
"selectLibrary": "Select Library",
"@selectLibrary": {
"description": "Title for library type selection bottom sheet"
},
"noRadioStations": "No radio stations",
"@noRadioStations": {
"description": "Empty state when no radio stations are available"
},
"addRadioStationsHint": "Add radio stations in Music Assistant",
"@addRadioStationsHint": {
"description": "Hint for adding radio stations"
},
"searchFailed": "Search failed. Please check your connection.",
"@searchFailed": {
"description": "Search error message"
@@ -610,6 +675,21 @@
"description": "Radio button label"
},
"playingRadioStation": "Playing {name}",
"@playingRadioStation": {
"description": "Status when playing radio station",
"placeholders": {
"name": {
"type": "String"
}
}
},
"addedToQueue": "Added to queue",
"@addedToQueue": {
"description": "Confirmation when item added to queue"
},
"inLibrary": "In Library",
"@inLibrary": {
"description": "In library section title"
@@ -635,6 +715,11 @@
"description": "Status when playing track"
},
"nowPlaying": "Now playing",
"@nowPlaying": {
"description": "Label shown when device selector is open to indicate current player"
},
"markedAsFinished": "{name} marked as finished",
"@markedAsFinished": {
"description": "Audiobook marked finished",
@@ -975,6 +1060,11 @@
"description": "Spanish language"
},
"french": "French",
"@french": {
"description": "French language"
},
"noTracksInPlaylist": "No tracks in playlist",
"sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year",
@@ -992,10 +1082,16 @@
"showFavoriteAlbums": "Show a row of your favorite albums",
"showFavoriteArtists": "Show a row of your favorite artists",
"showFavoriteTracks": "Show a row of your favorite tracks",
"showFavoritePlaylists": "Show a row of your favorite playlists",
"showFavoriteRadioStations": "Show a row of your favorite radio stations",
"showFavoritePodcasts": "Show a row of your favorite podcasts",
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
"addedToFavorites": "Added to favorites",
"removedFromFavorites": "Removed from favorites",
"addedToLibrary": "Added to library",
"removedFromLibrary": "Removed from library",
"addToLibrary": "Add to library",
"unknown": "Unknown",
"noUpcomingTracks": "No upcoming tracks",
"showAll": "Show all",
@@ -1006,6 +1102,10 @@
"shows": "Shows",
"podcasts": "Podcasts",
"podcastSupportComingSoon": "Podcast support coming soon",
"noPodcasts": "No podcasts",
"addPodcastsHint": "Subscribe to podcasts in Music Assistant",
"episodes": "Episodes",
"episode": "Episode",
"playlist": "Playlist",
"connectionError": "Connection Error",
"twoColumnGrid": "2-column grid",
@@ -1073,7 +1173,7 @@
"description": "Hint for Last.fm API key field"
},
"swipeToSwitchDevice": "Swipe to switch device",
"swipeToSwitchDevice": "Swipe to change player",
"@swipeToSwitchDevice": {
"description": "Hint for device switching gesture"
},
@@ -1128,6 +1228,21 @@
"description": "Singular form of audiobook"
},
"albumSingular": "Album",
"@albumSingular": {
"description": "Singular form of album for search type indicator"
},
"trackSingular": "Track",
"@trackSingular": {
"description": "Singular form of track for search type indicator"
},
"podcastSingular": "Podcast",
"@podcastSingular": {
"description": "Singular form of podcast for search type indicator"
},
"trackCount": "{count} {count, plural, =1{track} other{tracks}}",
"@trackCount": {
"description": "Track count with plural",
@@ -1143,6 +1258,36 @@
"description": "Description for Material You theme option"
},
"accentColor": "Accent Color",
"@accentColor": {
"description": "Accent color selection label"
},
"players": "Players",
"@players": {
"description": "Players settings section title"
},
"preferLocalPlayer": "Prefer Local Player",
"@preferLocalPlayer": {
"description": "Setting to always prefer local player"
},
"preferLocalPlayerDescription": "Always select this device first when available",
"@preferLocalPlayerDescription": {
"description": "Description for prefer local player setting"
},
"smartSortPlayers": "Smart Sort",
"@smartSortPlayers": {
"description": "Setting to sort players by status"
},
"smartSortPlayersDescription": "Sort by status (playing, on, off) instead of alphabetically",
"@smartSortPlayersDescription": {
"description": "Description for smart sort players setting"
},
"playerStateUnavailable": "Unavailable",
"@playerStateUnavailable": {
"description": "Player state when unavailable"
@@ -1158,8 +1303,133 @@
"description": "Player state when idle"
},
"playerStateExternalSource": "External Source",
"@playerStateExternalSource": {
"description": "Player state when playing from external source (optical, Spotify, etc.)"
},
"playerSelected": "Selected",
"@playerSelected": {
"description": "Label for selected player"
},
"actionQueuedForSync": "Will sync when online",
"@actionQueuedForSync": {
"description": "Message when action is queued for sync while offline"
},
"pendingOfflineActions": "{count} pending sync",
"@pendingOfflineActions": {
"description": "Shows number of pending offline actions",
"placeholders": {
"count": {
"type": "int"
}
}
},
"hintsAndTips": "Hints & Tips",
"@hintsAndTips": {
"description": "Settings section title for hints"
},
"showHints": "Show Hints",
"@showHints": {
"description": "Toggle label for showing hints"
},
"showHintsDescription": "Display helpful tips for discovering features",
"@showHintsDescription": {
"description": "Description for show hints toggle"
},
"pullToSelectPlayers": "Pull to select players",
"@pullToSelectPlayers": {
"description": "Hint shown when mini player bounces"
},
"holdToSync": "Long-press to sync",
"@holdToSync": {
"description": "Hint for long-press to sync players"
},
"swipeToAdjustVolume": "Swipe to adjust volume",
"@swipeToAdjustVolume": {
"description": "Hint for swiping left/right to adjust player volume"
},
"selectPlayerHint": "Choose a player, or dismiss by swiping down",
"@selectPlayerHint": {
"description": "First-time hint for selecting a player in device selector"
},
"welcomeToEnsemble": "Welcome to Ensemble",
"@welcomeToEnsemble": {
"description": "Welcome message title for onboarding"
},
"welcomeMessage": "By default your phone is the selected player.\nPull the mini player down to select another player.",
"@welcomeMessage": {
"description": "Welcome message body explaining how to select players"
},
"skip": "Skip",
"@skip": {
"description": "Button to skip onboarding or hints"
},
"dismissPlayerHint": "Swipe down, tap outside, or press back to return",
"@dismissPlayerHint": {
"description": "Hint explaining how to dismiss the player selector"
},
"playingAlbum": "Playing {albumName}",
"@playingAlbum": {
"description": "Message shown when starting to play an album",
"placeholders": {
"albumName": {
"type": "String"
}
}
},
"playingPlaylist": "Playing {playlistName}",
"@playingPlaylist": {
"description": "Message shown when starting to play a playlist",
"placeholders": {
"playlistName": {
"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"
}
}
+25 -2
View File
@@ -66,6 +66,12 @@
"tracks": "Canciones",
"playlists": "Listas de reproducción",
"audiobooks": "Audiolibros",
"music": "Música",
"radio": "Radio",
"stations": "Emisoras",
"selectLibrary": "Seleccionar Biblioteca",
"noRadioStations": "No hay emisoras de radio",
"addRadioStationsHint": "Añade emisoras de radio en Music Assistant",
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
"queue": "Cola",
"playerQueue": "Cola de {playerName}",
@@ -225,6 +231,7 @@
}
},
"playingTrack": "Reproduciendo canción",
"nowPlaying": "Reproduciendo ahora",
"markedAsFinished": "{name} marcado como terminado",
"@markedAsFinished": {
"placeholders": {
@@ -353,6 +360,7 @@
"english": "Inglés",
"german": "Alemán",
"spanish": "Español",
"french": "Francés",
"noTracksInPlaylist": "No hay canciones en la lista",
"sortAlphabetically": "Ordenar alfabéticamente",
"sortByYear": "Ordenar por año",
@@ -433,7 +441,7 @@
"metadataApisDescription": "Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.",
"lastFmApiKey": "Clave API de Last.fm",
"lastFmApiKeyHint": "Obtén una clave gratuita en last.fm/api",
"swipeToSwitchDevice": "Desliza para cambiar de dispositivo",
"swipeToSwitchDevice": "Desliza para cambiar dispositivo",
"chapterNumber": "Capítulo {number}",
"@chapterNumber": {
"placeholders": {
@@ -465,8 +473,23 @@
}
},
"materialYouDescription": "Usar colores del sistema (Android 12+)",
"accentColor": "Color de acento",
"players": "Dispositivos",
"preferLocalPlayer": "Preferir dispositivo local",
"preferLocalPlayerDescription": "Seleccionar siempre este dispositivo primero cuando esté disponible",
"smartSortPlayers": "Orden inteligente",
"smartSortPlayersDescription": "Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente",
"playerStateUnavailable": "No disponible",
"playerStateOff": "Apagado",
"playerStateIdle": "Inactivo",
"playerSelected": "Seleccionado"
"playerStateExternalSource": "Fuente Externa",
"playerSelected": "Seleccionado",
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
"pendingOfflineActions": "{count} sincronización pendiente",
"hintsAndTips": "Consejos y Sugerencias",
"showHints": "Mostrar Sugerencias",
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
"holdToSync": "Mantén presionado para sync",
"swipeToAdjustVolume": "Desliza para ajustar volumen"
}
+1400
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+983
View File
@@ -0,0 +1,983 @@
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for German (`de`).
class SDe extends S {
SDe([String locale = 'de']) : super(locale);
@override
String get appTitle => 'Ensemble';
@override
String get connecting => 'Verbinde...';
@override
String get connected => 'Verbunden';
@override
String get disconnected => 'Getrennt';
@override
String get serverAddress => 'Serveradresse';
@override
String get serverAddressHint => 'z.B. music.example.com oder 192.168.1.100';
@override
String get yourName => 'Dein Name';
@override
String get yourFirstName => 'Dein Vorname';
@override
String get portOptional => 'Port (Optional)';
@override
String get portHint => 'z.B. 8095 oder leer lassen';
@override
String get portDescription => 'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
@override
String get username => 'Benutzername';
@override
String get password => 'Passwort';
@override
String get detectAndConnect => 'Erkennen & Verbinden';
@override
String get connect => 'Verbinden';
@override
String get disconnect => 'Trennen';
@override
String get authServerUrlOptional => 'Auth-Server URL (Optional)';
@override
String get authServerUrlHint => 'z.B. auth.example.com (wenn abweichend vom Server)';
@override
String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server';
@override
String detectedAuthType(String authType) {
return 'Erkannt: $authType';
}
@override
String get serverRequiresAuth => 'Dieser Server erfordert Authentifizierung';
@override
String get tailscaleVpnConnection => 'Tailscale VPN-Verbindung';
@override
String get unencryptedConnection => 'Unverschlusselte Verbindung';
@override
String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)';
@override
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
@override
String get httpNotEncrypted => 'HTTP-Verbindung - Daten sind nicht verschlusselt';
@override
String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein';
@override
String get pleaseEnterName => 'Bitte gib deinen Namen ein';
@override
String get pleaseEnterValidPort => 'Bitte gib eine gultige Portnummer ein (1-65535)';
@override
String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein';
@override
String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
@override
String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
@override
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
@override
String get detectingAuth => 'Erkenne Authentifizierung...';
@override
String get cannotDetermineAuth => 'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
@override
String get noAuthentication => 'Keine Authentifizierung';
@override
String get httpBasicAuth => 'HTTP Basic Auth';
@override
String get authelia => 'Authelia';
@override
String get musicAssistantLogin => 'Music Assistant Anmeldung';
@override
String get pressBackToMinimize => 'Erneut drucken zum Minimieren';
@override
String get recentlyPlayed => 'Zuletzt gespielt';
@override
String get discoverArtists => 'Kunstler entdecken';
@override
String get discoverAlbums => 'Alben entdecken';
@override
String get continueListening => 'Weiterhoren';
@override
String get discoverAudiobooks => 'Horbücher entdecken';
@override
String get discoverSeries => 'Serien entdecken';
@override
String get favoriteAlbums => 'Lieblingsalben';
@override
String get favoriteArtists => 'Lieblingskünstler';
@override
String get favoriteTracks => 'Lieblingssongs';
@override
String get favoritePlaylists => 'Favorite Playlists';
@override
String get favoriteRadioStations => 'Favorite Radio Stations';
@override
String get favoritePodcasts => 'Favorite Podcasts';
@override
String get searchMusic => 'Musik suchen...';
@override
String get searchForContent => 'Nach Künstlern, Alben oder Songs suchen';
@override
String get recentSearches => 'Recent Searches';
@override
String get clearSearchHistory => 'Clear Search History';
@override
String get searchHistoryCleared => 'Search history cleared';
@override
String get libraryOnly => 'Library only';
@override
String get retry => 'Wiederholen';
@override
String get all => 'Alle';
@override
String get artists => 'Künstler';
@override
String get albums => 'Alben';
@override
String get tracks => 'Songs';
@override
String get playlists => 'Wiedergabelisten';
@override
String get audiobooks => 'Hörbücher';
@override
String get music => 'Musik';
@override
String get radio => 'Radio';
@override
String get stations => 'Sender';
@override
String get selectLibrary => 'Bibliothek auswählen';
@override
String get noRadioStations => 'Keine Radiosender';
@override
String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu';
@override
String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
@override
String get queue => 'Warteschlange';
@override
String playerQueue(String playerName) {
return '$playerName Warteschlange';
}
@override
String get noPlayerSelected => 'Kein Player ausgewählt';
@override
String get undo => 'Rückgängig';
@override
String removedItem(String itemName) {
return '$itemName entfernt';
}
@override
String get settings => 'Einstellungen';
@override
String get debugLogs => 'Debug-Protokolle';
@override
String get themeMode => 'Design';
@override
String get light => 'Hell';
@override
String get dark => 'Dunkel';
@override
String get system => 'System';
@override
String get pullToRefresh => 'Ziehe zum Aktualisieren der Bibliothek';
@override
String get viewDebugLogs => 'Debug-Protokolle anzeigen';
@override
String get viewAllPlayers => 'Alle Player anzeigen';
@override
String get copyLogs => 'Protokolle kopieren';
@override
String get clearLogs => 'Protokolle löschen';
@override
String get copyList => 'Liste kopieren';
@override
String get close => 'Schließen';
@override
String allPlayersCount(int count) {
return 'Alle Player ($count)';
}
@override
String errorLoadingPlayers(String error) {
return 'Fehler beim Laden der Player: $error';
}
@override
String get logsCopied => 'Protokolle in Zwischenablage kopiert';
@override
String get logsCleared => 'Protokolle gelöscht';
@override
String get playerListCopied => 'Playerliste in Zwischenablage kopiert!';
@override
String get noLogsYet => 'Noch keine Protokolle';
@override
String get infoPlus => 'Info+';
@override
String get warnings => 'Warnungen';
@override
String get errors => 'Fehler';
@override
String showingEntries(int filtered, int total) {
return 'Zeige $filtered von $total Einträgen';
}
@override
String get thisDevice => 'Dieses Gerät';
@override
String get ghostPlayer => 'Geist-Player (Duplikat)';
@override
String get unavailableCorrupt => 'Nicht verfügbar/Beschädigt';
@override
String playerId(String id) {
return 'ID: $id';
}
@override
String playerInfo(String available, String provider) {
return 'Verfügbar: $available | Anbieter: $provider';
}
@override
String get shareBugReport => 'Fehlerbericht teilen';
@override
String get moreOptions => 'Weitere Optionen';
@override
String failedToUpdateFavorite(String error) {
return 'Favorit konnte nicht aktualisiert werden: $error';
}
@override
String get noPlayersAvailable => 'Keine Player verfügbar';
@override
String get albumAddedToQueue => 'Album zur Warteschlange hinzugefügt';
@override
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
@override
String get play => 'Abspielen';
@override
String get noPlayersFound => 'Keine Player gefunden';
@override
String startingRadio(String name) {
return 'Starte $name Radio';
}
@override
String failedToStartRadio(String error) {
return 'Radio konnte nicht gestartet werden: $error';
}
@override
String startingRadioOnPlayer(String name, String playerName) {
return 'Starte $name Radio auf $playerName';
}
@override
String addedRadioToQueue(String name) {
return '$name Radio zur Warteschlange hinzugefügt';
}
@override
String failedToAddToQueue(String error) {
return 'Konnte nicht zur Warteschlange hinzufügen: $error';
}
@override
String playingRadioStation(String name) {
return 'Playing $name';
}
@override
String get addedToQueue => 'Added to queue';
@override
String get inLibrary => 'In Bibliothek';
@override
String get noAlbumsFound => 'Keine Alben gefunden';
@override
String playing(String name) {
return 'Spiele $name';
}
@override
String get playingTrack => 'Spiele Song';
@override
String get nowPlaying => 'Wird gespielt';
@override
String markedAsFinished(String name) {
return '$name als beendet markiert';
}
@override
String failedToMarkFinished(String error) {
return 'Konnte nicht als beendet markieren: $error';
}
@override
String markedAsUnplayed(String name) {
return '$name als ungespielt markiert';
}
@override
String failedToMarkUnplayed(String error) {
return 'Konnte nicht als ungespielt markieren: $error';
}
@override
String failedToPlay(String error) {
return 'Abspielen fehlgeschlagen: $error';
}
@override
String get markAsFinished => 'Als beendet markieren';
@override
String get markAsUnplayed => 'Als ungespielt markieren';
@override
String byAuthor(String author) {
return 'Von $author';
}
@override
String audiobookCount(int count) {
return '$count Hörbuch/Hörbücher';
}
@override
String get loading => 'Laden...';
@override
String bookCount(int count) {
return '$count Buch/Bücher';
}
@override
String get books => 'Bücher';
@override
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
@override
String get tapHeartAudiobook => 'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
@override
String get noAudiobooks => 'Keine Hörbücher';
@override
String get addAudiobooksHint => 'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
@override
String get noFavoriteArtists => 'Keine Lieblingskünstler';
@override
String get tapHeartArtist => 'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
@override
String get noFavoriteAlbums => 'Keine Lieblingsalben';
@override
String get tapHeartAlbum => 'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
@override
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
@override
String get tapHeartPlaylist => 'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
@override
String get noFavoriteTracks => 'Keine Lieblingssongs';
@override
String get longPressTrackHint => 'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
@override
String get loadSeries => 'Serien laden';
@override
String get notConnected => 'Nicht mit Music Assistant verbunden';
@override
String get notConnectedTitle => 'Nicht verbunden';
@override
String get connectHint => 'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
@override
String get configureServer => 'Server konfigurieren';
@override
String get noArtistsFound => 'Keine Künstler gefunden';
@override
String get noTracksFound => 'Keine Songs gefunden';
@override
String get noPlaylistsFound => 'Keine Wiedergabelisten gefunden';
@override
String get queueIsEmpty => 'Warteschlange ist leer';
@override
String get noResultsFound => 'Keine Ergebnisse gefunden';
@override
String get refresh => 'Aktualisieren';
@override
String get debugConsole => 'Debug-Konsole';
@override
String get copy => 'Kopieren';
@override
String get clear => 'Löschen';
@override
String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
@override
String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
@override
String get showDebug => 'Debug anzeigen';
@override
String get hideDebug => 'Debug ausblenden';
@override
String get chapters => 'Kapitel';
@override
String get noChapters => 'Keine Kapitel';
@override
String get noChapterInfo => 'Dieses Hörbuch hat keine Kapitelinformationen';
@override
String errorSeeking(String error) {
return 'Fehler beim Spulen: $error';
}
@override
String error(String error) {
return 'Fehler: $error';
}
@override
String get home => 'Start';
@override
String get library => 'Bibliothek';
@override
String get homeScreen => 'Startbildschirm';
@override
String get metadataApis => 'Metadaten-APIs';
@override
String get theme => 'Design';
@override
String get materialYou => 'Material You';
@override
String get adaptiveTheme => 'Adaptives Design';
@override
String get language => 'Sprache';
@override
String get english => 'Englisch';
@override
String get german => 'Deutsch';
@override
String get spanish => 'Spanisch';
@override
String get french => 'Französisch';
@override
String get noTracksInPlaylist => 'Keine Songs in Wiedergabeliste';
@override
String get sortAlphabetically => 'Alphabetisch sortieren';
@override
String get sortByYear => 'Nach Jahr sortieren';
@override
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren';
@override
String get listView => 'Listenansicht';
@override
String get gridView => 'Rasteransicht';
@override
String get noBooksInSeries => 'Keine Bücher in dieser Serie gefunden';
@override
String get artist => 'Künstler';
@override
String get showRecentlyPlayedAlbums => 'Kürzlich gespielte Alben anzeigen';
@override
String get showRandomArtists => 'Zufällige Künstler zum Entdecken anzeigen';
@override
String get showRandomAlbums => 'Zufällige Alben zum Entdecken anzeigen';
@override
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
@override
String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen';
@override
String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen';
@override
String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
@override
String get showFavoriteArtists => 'Lieblingskünstler anzeigen';
@override
String get showFavoriteTracks => 'Lieblingssongs anzeigen';
@override
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
@override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override
String get extractColorsFromArtwork => 'Farben aus Album- und Künstlercover extrahieren';
@override
String get chooseHomeScreenRows => 'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
@override
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
@override
String get removedFromFavorites => 'Aus Favoriten entfernt';
@override
String get addedToLibrary => 'Added to library';
@override
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
@override
String get unknown => 'Unbekannt';
@override
String get noUpcomingTracks => 'Keine weiteren Songs';
@override
String get showAll => 'Alle anzeigen';
@override
String get showFavoritesOnly => 'Nur Favoriten anzeigen';
@override
String get changeView => 'Ansicht ändern';
@override
String get authors => 'Autoren';
@override
String get series => 'Serien';
@override
String get shows => 'Sendungen';
@override
String get podcasts => 'Podcasts';
@override
String get podcastSupportComingSoon => 'Podcast-Unterstützung kommt bald';
@override
String get noPodcasts => 'No podcasts';
@override
String get addPodcastsHint => 'Subscribe to podcasts in Music Assistant';
@override
String get episodes => 'Episodes';
@override
String get episode => 'Episode';
@override
String get playlist => 'Wiedergabeliste';
@override
String get connectionError => 'Verbindungsfehler';
@override
String get twoColumnGrid => '2-Spalten-Raster';
@override
String get threeColumnGrid => '3-Spalten-Raster';
@override
String get fromProviders => 'Von Anbietern';
@override
String get resume => 'Fortsetzen';
@override
String get about => 'Über';
@override
String get inProgress => 'In Bearbeitung';
@override
String narratedBy(String narrators) {
return 'Gesprochen von $narrators';
}
@override
String get unknownNarrator => 'Unbekannter Sprecher';
@override
String get unknownAuthor => 'Unbekannter Autor';
@override
String get loadingChapters => 'Lade Kapitel...';
@override
String get noChapterInfoAvailable => 'Keine Kapitelinformationen verfügbar';
@override
String percentComplete(int percent) {
return '$percent% abgeschlossen';
}
@override
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
@override
String get theAudioDbApiKeyHint => '\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
@override
String get audiobookLibraries => 'Hörbuch-Bibliotheken';
@override
String get chooseAudiobookLibraries => 'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
@override
String get unknownLibrary => 'Unbekannte Bibliothek';
@override
String get noSeriesFound => 'Keine Serien gefunden';
@override
String get ensembleBugReport => 'Ensemble Fehlerbericht';
@override
String byOwner(String owner) {
return 'Von $owner';
}
@override
String get noSeriesAvailable => 'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
@override
String get pullToLoadSeries => 'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
@override
String get search => 'Suche';
@override
String get metadataApisDescription => 'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
@override
String get lastFmApiKey => 'Last.fm API-Schlüssel';
@override
String get lastFmApiKeyHint => 'Kostenlosen Schlüssel auf last.fm/api holen';
@override
String get swipeToSwitchDevice => 'Wischen um Gerät zu wechseln';
@override
String chapterNumber(int number) {
return 'Kapitel $number';
}
@override
String get pcmAudio => 'PCM-Audio';
@override
String get playOn => 'Abspielen auf...';
@override
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
@override
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
@override
String startRadioOn(String name) {
return '$name Radio starten auf...';
}
@override
String get book => 'Buch';
@override
String get audiobookSingular => 'Hörbuch';
@override
String get albumSingular => 'Album';
@override
String get trackSingular => 'Track';
@override
String get podcastSingular => 'Podcast';
@override
String trackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Songs',
one: 'Song',
);
return '$count $_temp0';
}
@override
String get materialYouDescription => 'Systemfarben verwenden (Android 12+)';
@override
String get accentColor => 'Akzentfarbe';
@override
String get players => 'Geräte';
@override
String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
@override
String get preferLocalPlayerDescription => 'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
@override
String get smartSortPlayers => 'Intelligente Sortierung';
@override
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
@override
String get playerStateUnavailable => 'Nicht verfügbar';
@override
String get playerStateOff => 'Aus';
@override
String get playerStateIdle => 'Inaktiv';
@override
String get playerStateExternalSource => 'Externe Quelle';
@override
String get playerSelected => 'Ausgewählt';
@override
String get actionQueuedForSync => 'Wird synchronisiert wenn online';
@override
String pendingOfflineActions(int count) {
return '$count ausstehende Synchronisierung';
}
@override
String get hintsAndTips => 'Tipps & Hinweise';
@override
String get showHints => 'Hinweise anzeigen';
@override
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
@override
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
@override
String get holdToSync => 'Gedrückt halten zum Sync';
@override
String get swipeToAdjustVolume => 'Wischen für Lautstärke';
@override
String get selectPlayerHint => 'Choose a player, or dismiss by swiping down';
@override
String get welcomeToEnsemble => 'Welcome to Ensemble';
@override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override
String get skip => 'Skip';
@override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
@override
String playingAlbum(String albumName) {
return 'Playing $albumName';
}
@override
String playingPlaylist(String playlistName) {
return 'Playing $playlistName';
}
@override
String get noTracks => 'No tracks';
@override
String get addTracksHint => 'Add some music to your library to see tracks here';
@override
String get noFavoriteRadioStations => 'No favorite radio stations';
@override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
@override
String get noFavoritePodcasts => 'No favorite podcasts';
@override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
}
+983
View File
@@ -0,0 +1,983 @@
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class SEn extends S {
SEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'Ensemble';
@override
String get connecting => 'Connecting...';
@override
String get connected => 'Connected';
@override
String get disconnected => 'Disconnected';
@override
String get serverAddress => 'Server Address';
@override
String get serverAddressHint => 'e.g., music.example.com or 192.168.1.100';
@override
String get yourName => 'Your Name';
@override
String get yourFirstName => 'Your first name';
@override
String get portOptional => 'Port (Optional)';
@override
String get portHint => 'e.g., 8095 or leave blank';
@override
String get portDescription => 'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
@override
String get username => 'Username';
@override
String get password => 'Password';
@override
String get detectAndConnect => 'Detect & Connect';
@override
String get connect => 'Connect';
@override
String get disconnect => 'Disconnect';
@override
String get authServerUrlOptional => 'Auth Server URL (Optional)';
@override
String get authServerUrlHint => 'e.g., auth.example.com (if different from server)';
@override
String get authServerUrlDescription => 'Leave empty if authentication is on the same server';
@override
String detectedAuthType(String authType) {
return 'Detected: $authType';
}
@override
String get serverRequiresAuth => 'This server requires authentication';
@override
String get tailscaleVpnConnection => 'Tailscale VPN Connection';
@override
String get unencryptedConnection => 'Unencrypted Connection';
@override
String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)';
@override
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
@override
String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
@override
String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address';
@override
String get pleaseEnterName => 'Please enter your name';
@override
String get pleaseEnterValidPort => 'Please enter a valid port number (1-65535)';
@override
String get pleaseEnterCredentials => 'Please enter username and password';
@override
String get authFailed => 'Authentication failed. Please check your credentials.';
@override
String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.';
@override
String get connectionFailed => 'Could not connect to server. Please check the address and try again.';
@override
String get detectingAuth => 'Detecting authentication...';
@override
String get cannotDetermineAuth => 'Cannot determine authentication requirements. Please check server URL.';
@override
String get noAuthentication => 'No Authentication';
@override
String get httpBasicAuth => 'HTTP Basic Auth';
@override
String get authelia => 'Authelia';
@override
String get musicAssistantLogin => 'Music Assistant Login';
@override
String get pressBackToMinimize => 'Press back again to minimize';
@override
String get recentlyPlayed => 'Recently Played';
@override
String get discoverArtists => 'Discover Artists';
@override
String get discoverAlbums => 'Discover Albums';
@override
String get continueListening => 'Continue Listening';
@override
String get discoverAudiobooks => 'Discover Audiobooks';
@override
String get discoverSeries => 'Discover Series';
@override
String get favoriteAlbums => 'Favorite Albums';
@override
String get favoriteArtists => 'Favorite Artists';
@override
String get favoriteTracks => 'Favorite Tracks';
@override
String get favoritePlaylists => 'Favorite Playlists';
@override
String get favoriteRadioStations => 'Favorite Radio Stations';
@override
String get favoritePodcasts => 'Favorite Podcasts';
@override
String get searchMusic => 'Search music...';
@override
String get searchForContent => 'Search for artists, albums, or tracks';
@override
String get recentSearches => 'Recent Searches';
@override
String get clearSearchHistory => 'Clear Search History';
@override
String get searchHistoryCleared => 'Search history cleared';
@override
String get libraryOnly => 'Library only';
@override
String get retry => 'Retry';
@override
String get all => 'All';
@override
String get artists => 'Artists';
@override
String get albums => 'Albums';
@override
String get tracks => 'Tracks';
@override
String get playlists => 'Playlists';
@override
String get audiobooks => 'Audiobooks';
@override
String get music => 'Music';
@override
String get radio => 'Radio';
@override
String get stations => 'Stations';
@override
String get selectLibrary => 'Select Library';
@override
String get noRadioStations => 'No radio stations';
@override
String get addRadioStationsHint => 'Add radio stations in Music Assistant';
@override
String get searchFailed => 'Search failed. Please check your connection.';
@override
String get queue => 'Queue';
@override
String playerQueue(String playerName) {
return '$playerName Queue';
}
@override
String get noPlayerSelected => 'No player selected';
@override
String get undo => 'Undo';
@override
String removedItem(String itemName) {
return 'Removed $itemName';
}
@override
String get settings => 'Settings';
@override
String get debugLogs => 'Debug Logs';
@override
String get themeMode => 'Theme Mode';
@override
String get light => 'Light';
@override
String get dark => 'Dark';
@override
String get system => 'System';
@override
String get pullToRefresh => 'Pull to refresh the library to apply changes';
@override
String get viewDebugLogs => 'View Debug Logs';
@override
String get viewAllPlayers => 'View All Players';
@override
String get copyLogs => 'Copy Logs';
@override
String get clearLogs => 'Clear Logs';
@override
String get copyList => 'Copy List';
@override
String get close => 'Close';
@override
String allPlayersCount(int count) {
return 'All Players ($count)';
}
@override
String errorLoadingPlayers(String error) {
return 'Error loading players: $error';
}
@override
String get logsCopied => 'Logs copied to clipboard';
@override
String get logsCleared => 'Logs cleared';
@override
String get playerListCopied => 'Player list copied to clipboard!';
@override
String get noLogsYet => 'No logs yet';
@override
String get infoPlus => 'Info+';
@override
String get warnings => 'Warnings';
@override
String get errors => 'Errors';
@override
String showingEntries(int filtered, int total) {
return 'Showing $filtered of $total entries';
}
@override
String get thisDevice => 'This device';
@override
String get ghostPlayer => 'Ghost player (duplicate)';
@override
String get unavailableCorrupt => 'Unavailable/Corrupt';
@override
String playerId(String id) {
return 'ID: $id';
}
@override
String playerInfo(String available, String provider) {
return 'Available: $available | Provider: $provider';
}
@override
String get shareBugReport => 'Share bug report';
@override
String get moreOptions => 'More options';
@override
String failedToUpdateFavorite(String error) {
return 'Failed to update favorite: $error';
}
@override
String get noPlayersAvailable => 'No players available';
@override
String get albumAddedToQueue => 'Album added to queue';
@override
String get tracksAddedToQueue => 'Tracks added to queue';
@override
String get play => 'Play';
@override
String get noPlayersFound => 'No players found';
@override
String startingRadio(String name) {
return 'Starting $name radio';
}
@override
String failedToStartRadio(String error) {
return 'Failed to start radio: $error';
}
@override
String startingRadioOnPlayer(String name, String playerName) {
return 'Starting $name radio on $playerName';
}
@override
String addedRadioToQueue(String name) {
return 'Added $name radio to queue';
}
@override
String failedToAddToQueue(String error) {
return 'Failed to add to queue: $error';
}
@override
String playingRadioStation(String name) {
return 'Playing $name';
}
@override
String get addedToQueue => 'Added to queue';
@override
String get inLibrary => 'In Library';
@override
String get noAlbumsFound => 'No albums found';
@override
String playing(String name) {
return 'Playing $name';
}
@override
String get playingTrack => 'Playing track';
@override
String get nowPlaying => 'Now playing';
@override
String markedAsFinished(String name) {
return '$name marked as finished';
}
@override
String failedToMarkFinished(String error) {
return 'Failed to mark as finished: $error';
}
@override
String markedAsUnplayed(String name) {
return '$name marked as unplayed';
}
@override
String failedToMarkUnplayed(String error) {
return 'Failed to mark as unplayed: $error';
}
@override
String failedToPlay(String error) {
return 'Failed to play: $error';
}
@override
String get markAsFinished => 'Mark as Finished';
@override
String get markAsUnplayed => 'Mark as Unplayed';
@override
String byAuthor(String author) {
return 'By $author';
}
@override
String audiobookCount(int count) {
return '$count audiobook(s)';
}
@override
String get loading => 'Loading...';
@override
String bookCount(int count) {
return '$count book(s)';
}
@override
String get books => 'Books';
@override
String get noFavoriteAudiobooks => 'No favorite audiobooks';
@override
String get tapHeartAudiobook => 'Tap the heart on an audiobook to add it to favorites';
@override
String get noAudiobooks => 'No audiobooks';
@override
String get addAudiobooksHint => 'Add audiobooks to your library to see them here';
@override
String get noFavoriteArtists => 'No favorite artists';
@override
String get tapHeartArtist => 'Tap the heart on an artist to add them to favorites';
@override
String get noFavoriteAlbums => 'No favorite albums';
@override
String get tapHeartAlbum => 'Tap the heart on an album to add it to favorites';
@override
String get noFavoritePlaylists => 'No favorite playlists';
@override
String get tapHeartPlaylist => 'Tap the heart on a playlist to add it to favorites';
@override
String get noFavoriteTracks => 'No favorite tracks';
@override
String get longPressTrackHint => 'Long-press a track and tap the heart to add it to favorites';
@override
String get loadSeries => 'Load Series';
@override
String get notConnected => 'Not connected to Music Assistant';
@override
String get notConnectedTitle => 'Not Connected';
@override
String get connectHint => 'Connect to your Music Assistant server to start listening';
@override
String get configureServer => 'Configure Server';
@override
String get noArtistsFound => 'No artists found';
@override
String get noTracksFound => 'No tracks found';
@override
String get noPlaylistsFound => 'No playlists found';
@override
String get queueIsEmpty => 'Queue is empty';
@override
String get noResultsFound => 'No results found';
@override
String get refresh => 'Refresh';
@override
String get debugConsole => 'Debug Console';
@override
String get copy => 'Copy';
@override
String get clear => 'Clear';
@override
String get noLogsToCopy => 'No logs to copy';
@override
String get noDebugLogsYet => 'No debug logs yet. Try detecting auth.';
@override
String get showDebug => 'Show Debug';
@override
String get hideDebug => 'Hide Debug';
@override
String get chapters => 'Chapters';
@override
String get noChapters => 'No chapters';
@override
String get noChapterInfo => 'This audiobook has no chapter information';
@override
String errorSeeking(String error) {
return 'Error seeking: $error';
}
@override
String error(String error) {
return 'Error: $error';
}
@override
String get home => 'Home';
@override
String get library => 'Library';
@override
String get homeScreen => 'Home Screen';
@override
String get metadataApis => 'Metadata APIs';
@override
String get theme => 'Theme';
@override
String get materialYou => 'Material You';
@override
String get adaptiveTheme => 'Adaptive Theme';
@override
String get language => 'Language';
@override
String get english => 'English';
@override
String get german => 'German';
@override
String get spanish => 'Spanish';
@override
String get french => 'French';
@override
String get noTracksInPlaylist => 'No tracks in playlist';
@override
String get sortAlphabetically => 'Sort alphabetically';
@override
String get sortByYear => 'Sort by year';
@override
String get sortBySeriesOrder => 'Sort by series order';
@override
String get listView => 'List view';
@override
String get gridView => 'Grid view';
@override
String get noBooksInSeries => 'No books found in this series';
@override
String get artist => 'Artist';
@override
String get showRecentlyPlayedAlbums => 'Show recently played albums';
@override
String get showRandomArtists => 'Show random artists to discover';
@override
String get showRandomAlbums => 'Show random albums to discover';
@override
String get showAudiobooksInProgress => 'Show audiobooks in progress';
@override
String get showRandomAudiobooks => 'Show random audiobooks to discover';
@override
String get showRandomSeries => 'Show random audiobook series to discover';
@override
String get showFavoriteAlbums => 'Show a row of your favorite albums';
@override
String get showFavoriteArtists => 'Show a row of your favorite artists';
@override
String get showFavoriteTracks => 'Show a row of your favorite tracks';
@override
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
@override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override
String get extractColorsFromArtwork => 'Extract colors from album and artist artwork';
@override
String get chooseHomeScreenRows => 'Choose which rows to display on the home screen';
@override
String get addedToFavorites => 'Added to favorites';
@override
String get removedFromFavorites => 'Removed from favorites';
@override
String get addedToLibrary => 'Added to library';
@override
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
@override
String get unknown => 'Unknown';
@override
String get noUpcomingTracks => 'No upcoming tracks';
@override
String get showAll => 'Show all';
@override
String get showFavoritesOnly => 'Show favorites only';
@override
String get changeView => 'Change view';
@override
String get authors => 'Authors';
@override
String get series => 'Series';
@override
String get shows => 'Shows';
@override
String get podcasts => 'Podcasts';
@override
String get podcastSupportComingSoon => 'Podcast support coming soon';
@override
String get noPodcasts => 'No podcasts';
@override
String get addPodcastsHint => 'Subscribe to podcasts in Music Assistant';
@override
String get episodes => 'Episodes';
@override
String get episode => 'Episode';
@override
String get playlist => 'Playlist';
@override
String get connectionError => 'Connection Error';
@override
String get twoColumnGrid => '2-column grid';
@override
String get threeColumnGrid => '3-column grid';
@override
String get fromProviders => 'From Providers';
@override
String get resume => 'Resume';
@override
String get about => 'About';
@override
String get inProgress => 'In progress';
@override
String narratedBy(String narrators) {
return 'Narrated by $narrators';
}
@override
String get unknownNarrator => 'Unknown Narrator';
@override
String get unknownAuthor => 'Unknown Author';
@override
String get loadingChapters => 'Loading chapters...';
@override
String get noChapterInfoAvailable => 'No chapter information available';
@override
String percentComplete(int percent) {
return '$percent% complete';
}
@override
String get theAudioDbApiKey => 'TheAudioDB API Key';
@override
String get theAudioDbApiKeyHint => 'Use \"2\" for free tier or premium key';
@override
String get audiobookLibraries => 'Audiobook Libraries';
@override
String get chooseAudiobookLibraries => 'Choose which Audiobookshelf libraries to include';
@override
String get unknownLibrary => 'Unknown Library';
@override
String get noSeriesFound => 'No Series Found';
@override
String get ensembleBugReport => 'Ensemble Bug Report';
@override
String byOwner(String owner) {
return 'By $owner';
}
@override
String get noSeriesAvailable => 'No series available from your audiobook library.\nPull to refresh.';
@override
String get pullToLoadSeries => 'Pull down to load series\nfrom Music Assistant';
@override
String get search => 'Search';
@override
String get metadataApisDescription => 'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
@override
String get lastFmApiKey => 'Last.fm API Key';
@override
String get lastFmApiKeyHint => 'Get free key at last.fm/api';
@override
String get swipeToSwitchDevice => 'Swipe to change player';
@override
String chapterNumber(int number) {
return 'Chapter $number';
}
@override
String get pcmAudio => 'PCM Audio';
@override
String get playOn => 'Play on...';
@override
String get addAlbumToQueueOn => 'Add album to queue on...';
@override
String get addToQueueOn => 'Add to queue on...';
@override
String startRadioOn(String name) {
return 'Start $name radio on...';
}
@override
String get book => 'book';
@override
String get audiobookSingular => 'audiobook';
@override
String get albumSingular => 'Album';
@override
String get trackSingular => 'Track';
@override
String get podcastSingular => 'Podcast';
@override
String trackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count $_temp0';
}
@override
String get materialYouDescription => 'Use system colors (Android 12+)';
@override
String get accentColor => 'Accent Color';
@override
String get players => 'Players';
@override
String get preferLocalPlayer => 'Prefer Local Player';
@override
String get preferLocalPlayerDescription => 'Always select this device first when available';
@override
String get smartSortPlayers => 'Smart Sort';
@override
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
@override
String get playerStateUnavailable => 'Unavailable';
@override
String get playerStateOff => 'Off';
@override
String get playerStateIdle => 'Idle';
@override
String get playerStateExternalSource => 'External Source';
@override
String get playerSelected => 'Selected';
@override
String get actionQueuedForSync => 'Will sync when online';
@override
String pendingOfflineActions(int count) {
return '$count pending sync';
}
@override
String get hintsAndTips => 'Hints & Tips';
@override
String get showHints => 'Show Hints';
@override
String get showHintsDescription => 'Display helpful tips for discovering features';
@override
String get pullToSelectPlayers => 'Pull to select players';
@override
String get holdToSync => 'Long-press to sync';
@override
String get swipeToAdjustVolume => 'Swipe to adjust volume';
@override
String get selectPlayerHint => 'Choose a player, or dismiss by swiping down';
@override
String get welcomeToEnsemble => 'Welcome to Ensemble';
@override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override
String get skip => 'Skip';
@override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
@override
String playingAlbum(String albumName) {
return 'Playing $albumName';
}
@override
String playingPlaylist(String playlistName) {
return 'Playing $playlistName';
}
@override
String get noTracks => 'No tracks';
@override
String get addTracksHint => 'Add some music to your library to see tracks here';
@override
String get noFavoriteRadioStations => 'No favorite radio stations';
@override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
@override
String get noFavoritePodcasts => 'No favorite podcasts';
@override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
}
+983
View File
@@ -0,0 +1,983 @@
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Spanish Castilian (`es`).
class SEs extends S {
SEs([String locale = 'es']) : super(locale);
@override
String get appTitle => 'Ensemble';
@override
String get connecting => 'Conectando...';
@override
String get connected => 'Conectado';
@override
String get disconnected => 'Desconectado';
@override
String get serverAddress => 'Dirección del servidor';
@override
String get serverAddressHint => 'ej., music.example.com o 192.168.1.100';
@override
String get yourName => 'Tu nombre';
@override
String get yourFirstName => 'Tu nombre de pila';
@override
String get portOptional => 'Puerto (Opcional)';
@override
String get portHint => 'ej., 8095 o dejar en blanco';
@override
String get portDescription => 'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
@override
String get username => 'Usuario';
@override
String get password => 'Contraseña';
@override
String get detectAndConnect => 'Detectar y conectar';
@override
String get connect => 'Conectar';
@override
String get disconnect => 'Desconectar';
@override
String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)';
@override
String get authServerUrlHint => 'ej., auth.example.com (si es diferente del servidor)';
@override
String get authServerUrlDescription => 'Dejar vacío si la autenticación está en el mismo servidor';
@override
String detectedAuthType(String authType) {
return 'Detectado: $authType';
}
@override
String get serverRequiresAuth => 'Este servidor requiere autenticación';
@override
String get tailscaleVpnConnection => 'Conexión VPN Tailscale';
@override
String get unencryptedConnection => 'Conexión sin cifrar';
@override
String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)';
@override
String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa';
@override
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
@override
String get pleaseEnterServerAddress => 'Por favor, introduce la dirección de tu servidor Music Assistant';
@override
String get pleaseEnterName => 'Por favor, introduce tu nombre';
@override
String get pleaseEnterValidPort => 'Por favor, introduce un número de puerto válido (1-65535)';
@override
String get pleaseEnterCredentials => 'Por favor, introduce usuario y contraseña';
@override
String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.';
@override
String get maLoginFailed => 'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
@override
String get connectionFailed => 'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
@override
String get detectingAuth => 'Detectando autenticación...';
@override
String get cannotDetermineAuth => 'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
@override
String get noAuthentication => 'Sin autenticación';
@override
String get httpBasicAuth => 'Autenticación HTTP Basic';
@override
String get authelia => 'Authelia';
@override
String get musicAssistantLogin => 'Inicio de sesión de Music Assistant';
@override
String get pressBackToMinimize => 'Presiona atrás de nuevo para minimizar';
@override
String get recentlyPlayed => 'Reproducido recientemente';
@override
String get discoverArtists => 'Descubrir artistas';
@override
String get discoverAlbums => 'Descubrir álbumes';
@override
String get continueListening => 'Continuar escuchando';
@override
String get discoverAudiobooks => 'Descubrir audiolibros';
@override
String get discoverSeries => 'Descubrir series';
@override
String get favoriteAlbums => 'Álbumes favoritos';
@override
String get favoriteArtists => 'Artistas favoritos';
@override
String get favoriteTracks => 'Canciones favoritas';
@override
String get favoritePlaylists => 'Favorite Playlists';
@override
String get favoriteRadioStations => 'Favorite Radio Stations';
@override
String get favoritePodcasts => 'Favorite Podcasts';
@override
String get searchMusic => 'Buscar música...';
@override
String get searchForContent => 'Buscar artistas, álbumes o canciones';
@override
String get recentSearches => 'Recent Searches';
@override
String get clearSearchHistory => 'Clear Search History';
@override
String get searchHistoryCleared => 'Search history cleared';
@override
String get libraryOnly => 'Library only';
@override
String get retry => 'Reintentar';
@override
String get all => 'Todo';
@override
String get artists => 'Artistas';
@override
String get albums => 'Álbumes';
@override
String get tracks => 'Canciones';
@override
String get playlists => 'Listas de reproducción';
@override
String get audiobooks => 'Audiolibros';
@override
String get music => 'Música';
@override
String get radio => 'Radio';
@override
String get stations => 'Emisoras';
@override
String get selectLibrary => 'Seleccionar Biblioteca';
@override
String get noRadioStations => 'No hay emisoras de radio';
@override
String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant';
@override
String get searchFailed => 'Búsqueda fallida. Por favor, verifica tu conexión.';
@override
String get queue => 'Cola';
@override
String playerQueue(String playerName) {
return 'Cola de $playerName';
}
@override
String get noPlayerSelected => 'Ningún reproductor seleccionado';
@override
String get undo => 'Deshacer';
@override
String removedItem(String itemName) {
return 'Eliminado $itemName';
}
@override
String get settings => 'Ajustes';
@override
String get debugLogs => 'Registros de depuración';
@override
String get themeMode => 'Modo de tema';
@override
String get light => 'Claro';
@override
String get dark => 'Oscuro';
@override
String get system => 'Sistema';
@override
String get pullToRefresh => 'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
@override
String get viewDebugLogs => 'Ver registros de depuración';
@override
String get viewAllPlayers => 'Ver todos los reproductores';
@override
String get copyLogs => 'Copiar registros';
@override
String get clearLogs => 'Borrar registros';
@override
String get copyList => 'Copiar lista';
@override
String get close => 'Cerrar';
@override
String allPlayersCount(int count) {
return 'Todos los reproductores ($count)';
}
@override
String errorLoadingPlayers(String error) {
return 'Error al cargar reproductores: $error';
}
@override
String get logsCopied => 'Registros copiados al portapapeles';
@override
String get logsCleared => 'Registros borrados';
@override
String get playerListCopied => 'Lista de reproductores copiada al portapapeles';
@override
String get noLogsYet => 'Aún no hay registros';
@override
String get infoPlus => 'Info+';
@override
String get warnings => 'Advertencias';
@override
String get errors => 'Errores';
@override
String showingEntries(int filtered, int total) {
return 'Mostrando $filtered de $total entradas';
}
@override
String get thisDevice => 'Este dispositivo';
@override
String get ghostPlayer => 'Reproductor fantasma (duplicado)';
@override
String get unavailableCorrupt => 'No disponible/Corrupto';
@override
String playerId(String id) {
return 'ID: $id';
}
@override
String playerInfo(String available, String provider) {
return 'Disponible: $available | Proveedor: $provider';
}
@override
String get shareBugReport => 'Compartir informe de errores';
@override
String get moreOptions => 'Más opciones';
@override
String failedToUpdateFavorite(String error) {
return 'Error al actualizar favorito: $error';
}
@override
String get noPlayersAvailable => 'No hay reproductores disponibles';
@override
String get albumAddedToQueue => 'Álbum añadido a la cola';
@override
String get tracksAddedToQueue => 'Canciones añadidas a la cola';
@override
String get play => 'Reproducir';
@override
String get noPlayersFound => 'No se encontraron reproductores';
@override
String startingRadio(String name) {
return 'Iniciando radio de $name';
}
@override
String failedToStartRadio(String error) {
return 'Error al iniciar radio: $error';
}
@override
String startingRadioOnPlayer(String name, String playerName) {
return 'Iniciando radio de $name en $playerName';
}
@override
String addedRadioToQueue(String name) {
return 'Radio de $name añadida a la cola';
}
@override
String failedToAddToQueue(String error) {
return 'Error al añadir a la cola: $error';
}
@override
String playingRadioStation(String name) {
return 'Playing $name';
}
@override
String get addedToQueue => 'Added to queue';
@override
String get inLibrary => 'En la biblioteca';
@override
String get noAlbumsFound => 'No se encontraron álbumes';
@override
String playing(String name) {
return 'Reproduciendo $name';
}
@override
String get playingTrack => 'Reproduciendo canción';
@override
String get nowPlaying => 'Reproduciendo ahora';
@override
String markedAsFinished(String name) {
return '$name marcado como terminado';
}
@override
String failedToMarkFinished(String error) {
return 'Error al marcar como terminado: $error';
}
@override
String markedAsUnplayed(String name) {
return '$name marcado como no reproducido';
}
@override
String failedToMarkUnplayed(String error) {
return 'Error al marcar como no reproducido: $error';
}
@override
String failedToPlay(String error) {
return 'Error al reproducir: $error';
}
@override
String get markAsFinished => 'Marcar como terminado';
@override
String get markAsUnplayed => 'Marcar como no reproducido';
@override
String byAuthor(String author) {
return 'Por $author';
}
@override
String audiobookCount(int count) {
return '$count audiolibro(s)';
}
@override
String get loading => 'Cargando...';
@override
String bookCount(int count) {
return '$count libro(s)';
}
@override
String get books => 'Libros';
@override
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
@override
String get tapHeartAudiobook => 'Toca el corazón en un audiolibro para añadirlo a favoritos';
@override
String get noAudiobooks => 'No hay audiolibros';
@override
String get addAudiobooksHint => 'Añade audiolibros a tu biblioteca para verlos aquí';
@override
String get noFavoriteArtists => 'No hay artistas favoritos';
@override
String get tapHeartArtist => 'Toca el corazón en un artista para añadirlo a favoritos';
@override
String get noFavoriteAlbums => 'No hay álbumes favoritos';
@override
String get tapHeartAlbum => 'Toca el corazón en un álbum para añadirlo a favoritos';
@override
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
@override
String get tapHeartPlaylist => 'Toca el corazón en una lista para añadirla a favoritos';
@override
String get noFavoriteTracks => 'No hay canciones favoritas';
@override
String get longPressTrackHint => 'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
@override
String get loadSeries => 'Cargar series';
@override
String get notConnected => 'No conectado a Music Assistant';
@override
String get notConnectedTitle => 'No conectado';
@override
String get connectHint => 'Conéctate a tu servidor Music Assistant para empezar a escuchar';
@override
String get configureServer => 'Configurar servidor';
@override
String get noArtistsFound => 'No se encontraron artistas';
@override
String get noTracksFound => 'No se encontraron canciones';
@override
String get noPlaylistsFound => 'No se encontraron listas de reproducción';
@override
String get queueIsEmpty => 'La cola está vacía';
@override
String get noResultsFound => 'No se encontraron resultados';
@override
String get refresh => 'Actualizar';
@override
String get debugConsole => 'Consola de depuración';
@override
String get copy => 'Copiar';
@override
String get clear => 'Borrar';
@override
String get noLogsToCopy => 'No hay registros para copiar';
@override
String get noDebugLogsYet => 'Aún no hay registros de depuración. Intenta detectar autenticación.';
@override
String get showDebug => 'Mostrar depuración';
@override
String get hideDebug => 'Ocultar depuración';
@override
String get chapters => 'Capítulos';
@override
String get noChapters => 'Sin capítulos';
@override
String get noChapterInfo => 'Este audiolibro no tiene información de capítulos';
@override
String errorSeeking(String error) {
return 'Error al buscar: $error';
}
@override
String error(String error) {
return 'Error: $error';
}
@override
String get home => 'Inicio';
@override
String get library => 'Biblioteca';
@override
String get homeScreen => 'Pantalla de inicio';
@override
String get metadataApis => 'APIs de metadatos';
@override
String get theme => 'Tema';
@override
String get materialYou => 'Material You';
@override
String get adaptiveTheme => 'Tema adaptativo';
@override
String get language => 'Idioma';
@override
String get english => 'Inglés';
@override
String get german => 'Alemán';
@override
String get spanish => 'Español';
@override
String get french => 'Francés';
@override
String get noTracksInPlaylist => 'No hay canciones en la lista';
@override
String get sortAlphabetically => 'Ordenar alfabéticamente';
@override
String get sortByYear => 'Ordenar por año';
@override
String get sortBySeriesOrder => 'Ordenar por orden de serie';
@override
String get listView => 'Vista de lista';
@override
String get gridView => 'Vista de cuadrícula';
@override
String get noBooksInSeries => 'No se encontraron libros en esta serie';
@override
String get artist => 'Artista';
@override
String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente';
@override
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
@override
String get showRandomAlbums => 'Mostrar álbumes aleatorios para descubrir';
@override
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
@override
String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir';
@override
String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir';
@override
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
@override
String get showFavoriteArtists => 'Mostrar una fila de tus artistas favoritos';
@override
String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas';
@override
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
@override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override
String get extractColorsFromArtwork => 'Extraer colores de las portadas de álbumes y artistas';
@override
String get chooseHomeScreenRows => 'Elige qué filas mostrar en la pantalla de inicio';
@override
String get addedToFavorites => 'Añadido a favoritos';
@override
String get removedFromFavorites => 'Eliminado de favoritos';
@override
String get addedToLibrary => 'Added to library';
@override
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
@override
String get unknown => 'Desconocido';
@override
String get noUpcomingTracks => 'No hay canciones próximas';
@override
String get showAll => 'Mostrar todo';
@override
String get showFavoritesOnly => 'Mostrar solo favoritos';
@override
String get changeView => 'Cambiar vista';
@override
String get authors => 'Autores';
@override
String get series => 'Series';
@override
String get shows => 'Programas';
@override
String get podcasts => 'Podcasts';
@override
String get podcastSupportComingSoon => 'Soporte de podcasts próximamente';
@override
String get noPodcasts => 'No podcasts';
@override
String get addPodcastsHint => 'Subscribe to podcasts in Music Assistant';
@override
String get episodes => 'Episodes';
@override
String get episode => 'Episode';
@override
String get playlist => 'Lista de reproducción';
@override
String get connectionError => 'Error de conexión';
@override
String get twoColumnGrid => 'Cuadrícula de 2 columnas';
@override
String get threeColumnGrid => 'Cuadrícula de 3 columnas';
@override
String get fromProviders => 'De proveedores';
@override
String get resume => 'Continuar';
@override
String get about => 'Acerca de';
@override
String get inProgress => 'En progreso';
@override
String narratedBy(String narrators) {
return 'Narrado por $narrators';
}
@override
String get unknownNarrator => 'Narrador desconocido';
@override
String get unknownAuthor => 'Autor desconocido';
@override
String get loadingChapters => 'Cargando capítulos...';
@override
String get noChapterInfoAvailable => 'No hay información de capítulos disponible';
@override
String percentComplete(int percent) {
return '$percent% completado';
}
@override
String get theAudioDbApiKey => 'Clave API de TheAudioDB';
@override
String get theAudioDbApiKeyHint => 'Usa \"2\" para nivel gratuito o clave premium';
@override
String get audiobookLibraries => 'Bibliotecas de audiolibros';
@override
String get chooseAudiobookLibraries => 'Elige qué bibliotecas de Audiobookshelf incluir';
@override
String get unknownLibrary => 'Biblioteca desconocida';
@override
String get noSeriesFound => 'No se encontraron series';
@override
String get ensembleBugReport => 'Informe de errores de Ensemble';
@override
String byOwner(String owner) {
return 'Por $owner';
}
@override
String get noSeriesAvailable => 'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
@override
String get pullToLoadSeries => 'Desliza hacia abajo para cargar series\ndesde Music Assistant';
@override
String get search => 'Buscar';
@override
String get metadataApisDescription => 'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
@override
String get lastFmApiKey => 'Clave API de Last.fm';
@override
String get lastFmApiKeyHint => 'Obtén una clave gratuita en last.fm/api';
@override
String get swipeToSwitchDevice => 'Desliza para cambiar dispositivo';
@override
String chapterNumber(int number) {
return 'Capítulo $number';
}
@override
String get pcmAudio => 'Audio PCM';
@override
String get playOn => 'Reproducir en...';
@override
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
@override
String get addToQueueOn => 'Añadir a la cola en...';
@override
String startRadioOn(String name) {
return 'Iniciar radio de $name en...';
}
@override
String get book => 'libro';
@override
String get audiobookSingular => 'audiolibro';
@override
String get albumSingular => 'Album';
@override
String get trackSingular => 'Track';
@override
String get podcastSingular => 'Podcast';
@override
String trackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'canciones',
one: 'canción',
);
return '$count $_temp0';
}
@override
String get materialYouDescription => 'Usar colores del sistema (Android 12+)';
@override
String get accentColor => 'Color de acento';
@override
String get players => 'Dispositivos';
@override
String get preferLocalPlayer => 'Preferir dispositivo local';
@override
String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible';
@override
String get smartSortPlayers => 'Orden inteligente';
@override
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
@override
String get playerStateUnavailable => 'No disponible';
@override
String get playerStateOff => 'Apagado';
@override
String get playerStateIdle => 'Inactivo';
@override
String get playerStateExternalSource => 'Fuente Externa';
@override
String get playerSelected => 'Seleccionado';
@override
String get actionQueuedForSync => 'Se sincronizará cuando esté en línea';
@override
String pendingOfflineActions(int count) {
return '$count sincronización pendiente';
}
@override
String get hintsAndTips => 'Consejos y Sugerencias';
@override
String get showHints => 'Mostrar Sugerencias';
@override
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
@override
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
@override
String get holdToSync => 'Mantén presionado para sync';
@override
String get swipeToAdjustVolume => 'Desliza para ajustar volumen';
@override
String get selectPlayerHint => 'Choose a player, or dismiss by swiping down';
@override
String get welcomeToEnsemble => 'Welcome to Ensemble';
@override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override
String get skip => 'Skip';
@override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
@override
String playingAlbum(String albumName) {
return 'Playing $albumName';
}
@override
String playingPlaylist(String playlistName) {
return 'Playing $playlistName';
}
@override
String get noTracks => 'No tracks';
@override
String get addTracksHint => 'Add some music to your library to see tracks here';
@override
String get noFavoriteRadioStations => 'No favorite radio stations';
@override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
@override
String get noFavoritePodcasts => 'No favorite podcasts';
@override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
}
+983
View File
@@ -0,0 +1,983 @@
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for French (`fr`).
class SFr extends S {
SFr([String locale = 'fr']) : super(locale);
@override
String get appTitle => 'Ensemble';
@override
String get connecting => 'Connexion...';
@override
String get connected => 'Connecté';
@override
String get disconnected => 'Déconnecté';
@override
String get serverAddress => 'Adresse du serveur';
@override
String get serverAddressHint => 'ex., music.example.com ou 192.168.1.100';
@override
String get yourName => 'Votre nom';
@override
String get yourFirstName => 'Votre prénom';
@override
String get portOptional => 'Port (Facultatif)';
@override
String get portHint => 'ex., 8095 ou laisser vide';
@override
String get portDescription => 'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
@override
String get username => 'Nom d\'utilisateur';
@override
String get password => 'Mot de passe';
@override
String get detectAndConnect => 'Détecter et connecter';
@override
String get connect => 'Connectér';
@override
String get disconnect => 'Déconnectér';
@override
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
@override
String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)';
@override
String get authServerUrlDescription => 'Laisser vide si l\'authentification est sur le meme serveur';
@override
String detectedAuthType(String authType) {
return 'Detected: $authType';
}
@override
String get serverRequiresAuth => 'Ce serveur requiert une authentification';
@override
String get tailscaleVpnConnection => 'Connexion VPN Tailscale';
@override
String get unencryptedConnection => 'Connexion non chiffrée';
@override
String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)';
@override
String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli';
@override
String get httpNotEncrypted => 'Connexion HTTP - les donnees ne sont pas chiffrées';
@override
String get pleaseEnterServerAddress => 'Veuillez entrer l\'adresse de votre serveur Music Assistant';
@override
String get pleaseEnterName => 'Veuillez entrer votre nom';
@override
String get pleaseEnterValidPort => 'Veuillez entrer un numero de port valide (1-65535)';
@override
String get pleaseEnterCredentials => 'Veuillez entrer le nom d\'utilisateur et le mot de passe';
@override
String get authFailed => 'Echec de l\'authentification. Veuillez verifier vos identifiants.';
@override
String get maLoginFailed => 'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
@override
String get connectionFailed => 'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
@override
String get detectingAuth => 'Detection de l\'authentification...';
@override
String get cannotDetermineAuth => 'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
@override
String get noAuthentication => 'Pas d\'authentification';
@override
String get httpBasicAuth => 'HTTP Basic Auth';
@override
String get authelia => 'Authelia';
@override
String get musicAssistantLogin => 'Connexion Music Assistant';
@override
String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser';
@override
String get recentlyPlayed => 'Ecoutes recemment';
@override
String get discoverArtists => 'Découvrir des artistes';
@override
String get discoverAlbums => 'Découvrir des albums';
@override
String get continueListening => 'Continuer l\'écoute';
@override
String get discoverAudiobooks => 'Découvrir des livres audio';
@override
String get discoverSeries => 'Découvrir des séries';
@override
String get favoriteAlbums => 'Albums favoris';
@override
String get favoriteArtists => 'Artistes favoris';
@override
String get favoriteTracks => 'Pistes favorites';
@override
String get favoritePlaylists => 'Playlists favorites';
@override
String get favoriteRadioStations => 'Stations de radio favorites';
@override
String get favoritePodcasts => 'Podcasts favoris';
@override
String get searchMusic => 'Rechercher de la musique...';
@override
String get searchForContent => 'Rechercher des artistes, albums ou pistes';
@override
String get recentSearches => 'Recherches recentes';
@override
String get clearSearchHistory => 'Effacer l\'historique de recherche';
@override
String get searchHistoryCleared => 'Historique de recherche efface';
@override
String get libraryOnly => 'Bibliothèque uniquement';
@override
String get retry => 'Reessayer';
@override
String get all => 'Tout';
@override
String get artists => 'Artistes';
@override
String get albums => 'Albums';
@override
String get tracks => 'Pistes';
@override
String get playlists => 'Playlists';
@override
String get audiobooks => 'Livres audio';
@override
String get music => 'Musique';
@override
String get radio => 'Radio';
@override
String get stations => 'Stations';
@override
String get selectLibrary => 'Sélectionner la bibliothèque';
@override
String get noRadioStations => 'Aucune station de radio';
@override
String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant';
@override
String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.';
@override
String get queue => 'File d\'attente';
@override
String playerQueue(String playerName) {
return 'File d\'attente de $playerName';
}
@override
String get noPlayerSelected => 'Aucun lecteur selectionne';
@override
String get undo => 'Annuler';
@override
String removedItem(String itemName) {
return '$itemName supprimé';
}
@override
String get settings => 'Paramètres';
@override
String get debugLogs => 'Journaux de debogage';
@override
String get themeMode => 'Mode de theme';
@override
String get light => 'Clair';
@override
String get dark => 'Sombre';
@override
String get system => 'Système';
@override
String get pullToRefresh => 'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
@override
String get viewDebugLogs => 'Voir les journaux de debogage';
@override
String get viewAllPlayers => 'Voir tous les lecteurs';
@override
String get copyLogs => 'Copier les journaux';
@override
String get clearLogs => 'Effacer les journaux';
@override
String get copyList => 'Copier la liste';
@override
String get close => 'Fermer';
@override
String allPlayersCount(int count) {
return 'Tous les lecteurs ($count)';
}
@override
String errorLoadingPlayers(String error) {
return 'Erreur lors du chargement des lecteurs : $error';
}
@override
String get logsCopied => 'Journaux copies dans le presse-papiers';
@override
String get logsCleared => 'Journaux effaces';
@override
String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !';
@override
String get noLogsYet => 'Pas encore de journaux';
@override
String get infoPlus => 'Info+';
@override
String get warnings => 'Avertissements';
@override
String get errors => 'Erreurs';
@override
String showingEntries(int filtered, int total) {
return 'Affichage de $filtered sur $total entrées';
}
@override
String get thisDevice => 'Cet appareil';
@override
String get ghostPlayer => 'Lecteur fantome (doublon)';
@override
String get unavailableCorrupt => 'Indisponible/Corrompu';
@override
String playerId(String id) {
return 'ID : $id';
}
@override
String playerInfo(String available, String provider) {
return 'Disponible : $available | Fournisseur : $provider';
}
@override
String get shareBugReport => 'Partager le rapport de bogue';
@override
String get moreOptions => 'Plus d\'options';
@override
String failedToUpdateFavorite(String error) {
return 'Echec de la mise a jour du favori : $error';
}
@override
String get noPlayersAvailable => 'Aucun lecteur disponible';
@override
String get albumAddedToQueue => 'Album ajouté a la file d\'attente';
@override
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
@override
String get play => 'Lire';
@override
String get noPlayersFound => 'Aucun lecteur trouve';
@override
String startingRadio(String name) {
return 'Demarrage de la radio $name';
}
@override
String failedToStartRadio(String error) {
return 'Echec du demarrage de la radio : $error';
}
@override
String startingRadioOnPlayer(String name, String playerName) {
return 'Demarrage de la radio $name sur $playerName';
}
@override
String addedRadioToQueue(String name) {
return 'Radio $name ajoutée a la file d\'attente';
}
@override
String failedToAddToQueue(String error) {
return 'Echec de l\'ajout a la file d\'attente : $error';
}
@override
String playingRadioStation(String name) {
return 'Lecture de $name';
}
@override
String get addedToQueue => 'Ajoute a la file d\'attente';
@override
String get inLibrary => 'Dans la bibliothèque';
@override
String get noAlbumsFound => 'Aucun album trouve';
@override
String playing(String name) {
return 'Lecture de $name';
}
@override
String get playingTrack => 'Lecture de la piste';
@override
String get nowPlaying => 'En cours de lecture';
@override
String markedAsFinished(String name) {
return '$name marque comme termine';
}
@override
String failedToMarkFinished(String error) {
return 'Echec du marquage comme termine : $error';
}
@override
String markedAsUnplayed(String name) {
return '$name marque comme non lu';
}
@override
String failedToMarkUnplayed(String error) {
return 'Echec du marquage comme non lu : $error';
}
@override
String failedToPlay(String error) {
return 'Echec de la lecture : $error';
}
@override
String get markAsFinished => 'Marquer comme termine';
@override
String get markAsUnplayed => 'Marquer comme non lu';
@override
String byAuthor(String author) {
return 'Par $author';
}
@override
String audiobookCount(int count) {
return '$count livre(s) audio';
}
@override
String get loading => 'Chargement...';
@override
String bookCount(int count) {
return '$count livre(s)';
}
@override
String get books => 'Livres';
@override
String get noFavoriteAudiobooks => 'Aucun livre audio favori';
@override
String get tapHeartAudiobook => 'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
@override
String get noAudiobooks => 'Aucun livre audio';
@override
String get addAudiobooksHint => 'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
@override
String get noFavoriteArtists => 'Aucun artiste favori';
@override
String get tapHeartArtist => 'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
@override
String get noFavoriteAlbums => 'Aucun album favori';
@override
String get tapHeartAlbum => 'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
@override
String get noFavoritePlaylists => 'Aucune playlist favorite';
@override
String get tapHeartPlaylist => 'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
@override
String get noFavoriteTracks => 'Aucune piste favorite';
@override
String get longPressTrackHint => 'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
@override
String get loadSeries => 'Charger les séries';
@override
String get notConnected => 'Not connected to Music Assistant';
@override
String get notConnectedTitle => 'Not Connected';
@override
String get connectHint => 'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
@override
String get configureServer => 'Configurer le serveur';
@override
String get noArtistsFound => 'Aucun artiste trouve';
@override
String get noTracksFound => 'Aucune piste trouvee';
@override
String get noPlaylistsFound => 'Aucune playlist trouvee';
@override
String get queueIsEmpty => 'La file d\'attente est vide';
@override
String get noResultsFound => 'Aucun resultat trouve';
@override
String get refresh => 'Rafraichir';
@override
String get debugConsole => 'Console de debogage';
@override
String get copy => 'Copier';
@override
String get clear => 'Effacer';
@override
String get noLogsToCopy => 'Aucun journal a copier';
@override
String get noDebugLogsYet => 'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
@override
String get showDebug => 'Afficher le debogage';
@override
String get hideDebug => 'Masquer le debogage';
@override
String get chapters => 'Chapitres';
@override
String get noChapters => 'Aucun chapitre';
@override
String get noChapterInfo => 'Ce livre audio n\'a pas d\'informations de chapitre';
@override
String errorSeeking(String error) {
return 'Erreur de positionnement : $error';
}
@override
String error(String error) {
return 'Erreur : $error';
}
@override
String get home => 'Accueil';
@override
String get library => 'Bibliothèque';
@override
String get homeScreen => 'Ecran d\'accueil';
@override
String get metadataApis => 'API de metadonnees';
@override
String get theme => 'Theme';
@override
String get materialYou => 'Material You';
@override
String get adaptiveTheme => 'Theme adaptatif';
@override
String get language => 'Langue';
@override
String get english => 'Anglais';
@override
String get german => 'Allemand';
@override
String get spanish => 'Espagnol';
@override
String get french => 'Français';
@override
String get noTracksInPlaylist => 'Aucune piste dans la playlist';
@override
String get sortAlphabetically => 'Trier alphabétiquement';
@override
String get sortByYear => 'Trier par année';
@override
String get sortBySeriesOrder => 'Trier par ordre de série';
@override
String get listView => 'Vue en liste';
@override
String get gridView => 'Vue en grille';
@override
String get noBooksInSeries => 'Aucun livre trouve dans cette série';
@override
String get artist => 'Artiste';
@override
String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment';
@override
String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir';
@override
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
@override
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
@override
String get showRandomAudiobooks => 'Afficher des livres audio aléatoires a découvrir';
@override
String get showRandomSeries => 'Afficher des séries de livres audio aléatoires a découvrir';
@override
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
@override
String get showFavoriteArtists => 'Afficher une rangee de vos artistes favoris';
@override
String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites';
@override
String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites';
@override
String get showFavoriteRadioStations => 'Afficher une rangee de vos stations de radio favorites';
@override
String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris';
@override
String get extractColorsFromArtwork => 'Extraire les couleurs des pochettes d\'albums et d\'artistes';
@override
String get chooseHomeScreenRows => 'Choisir les rangees a afficher sur l\'ecran d\'accueil';
@override
String get addedToFavorites => 'Ajoute aux favoris';
@override
String get removedFromFavorites => 'Retire des favoris';
@override
String get addedToLibrary => 'Ajoute a la bibliothèque';
@override
String get removedFromLibrary => 'Retire de la bibliothèque';
@override
String get addToLibrary => 'Ajouter a la bibliothèque';
@override
String get unknown => 'Inconnu';
@override
String get noUpcomingTracks => 'Aucune piste a venir';
@override
String get showAll => 'Tout afficher';
@override
String get showFavoritesOnly => 'Afficher les favoris uniquement';
@override
String get changeView => 'Changer la vue';
@override
String get authors => 'Auteurs';
@override
String get series => 'Series';
@override
String get shows => 'Emissions';
@override
String get podcasts => 'Podcasts';
@override
String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible';
@override
String get noPodcasts => 'Aucun podcast';
@override
String get addPodcastsHint => 'Abonnez-vous a des podcasts dans Music Assistant';
@override
String get episodes => 'Episodes';
@override
String get episode => 'Episode';
@override
String get playlist => 'Playlist';
@override
String get connectionError => 'Erreur de connexion';
@override
String get twoColumnGrid => 'Grille a 2 colonnes';
@override
String get threeColumnGrid => 'Grille a 3 colonnes';
@override
String get fromProviders => 'Des fournisseurs';
@override
String get resume => 'Reprendre';
@override
String get about => 'A propos';
@override
String get inProgress => 'En cours';
@override
String narratedBy(String narrators) {
return 'Lu par $narrators';
}
@override
String get unknownNarrator => 'Narrateur inconnu';
@override
String get unknownAuthor => 'Auteur inconnu';
@override
String get loadingChapters => 'Chargement des chapitres...';
@override
String get noChapterInfoAvailable => 'Aucune information de chapitre disponible';
@override
String percentComplete(int percent) {
return '$percent% termine';
}
@override
String get theAudioDbApiKey => 'Cle API TheAudioDB';
@override
String get theAudioDbApiKeyHint => 'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
@override
String get audiobookLibraries => 'Bibliothèques de livres audio';
@override
String get chooseAudiobookLibraries => 'Choisir quelles bibliothèques Audiobookshelf inclure';
@override
String get unknownLibrary => 'Bibliothèque inconnue';
@override
String get noSeriesFound => 'Aucune série trouvee';
@override
String get ensembleBugReport => 'Rapport de bogue Ensemble';
@override
String byOwner(String owner) {
return 'Par $owner';
}
@override
String get noSeriesAvailable => 'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
@override
String get pullToLoadSeries => 'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
@override
String get search => 'Rechercher';
@override
String get metadataApisDescription => 'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
@override
String get lastFmApiKey => 'Cle API Last.fm';
@override
String get lastFmApiKeyHint => 'Obtenez une cle gratuite sur last.fm/api';
@override
String get swipeToSwitchDevice => 'Balayez pour changer de lecteur';
@override
String chapterNumber(int number) {
return 'Chapitre $number';
}
@override
String get pcmAudio => 'Audio PCM';
@override
String get playOn => 'Lire sur...';
@override
String get addAlbumToQueueOn => 'Ajouter l\'album a la file d\'attente sur...';
@override
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
@override
String startRadioOn(String name) {
return 'Demarrer la radio $name sur...';
}
@override
String get book => 'livre';
@override
String get audiobookSingular => 'livre audio';
@override
String get albumSingular => 'Album';
@override
String get trackSingular => 'Piste';
@override
String get podcastSingular => 'Podcast';
@override
String trackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count pistes',
one: '$count piste',
);
return '$_temp0';
}
@override
String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)';
@override
String get accentColor => 'Couleur d\'accentuation';
@override
String get players => 'Lecteurs';
@override
String get preferLocalPlayer => 'Préférer le lecteur local';
@override
String get preferLocalPlayerDescription => 'Toujours sélectionner cet appareil en premier si disponible';
@override
String get smartSortPlayers => 'Tri intelligent';
@override
String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
@override
String get playerStateUnavailable => 'Indisponible';
@override
String get playerStateOff => 'Eteint';
@override
String get playerStateIdle => 'Inactif';
@override
String get playerStateExternalSource => 'Source externe';
@override
String get playerSelected => 'Selectionne';
@override
String get actionQueuedForSync => 'Sera synchronise une fois en ligne';
@override
String pendingOfflineActions(int count) {
return '$count en attente de synchronisation';
}
@override
String get hintsAndTips => 'Astuces et conseils';
@override
String get showHints => 'Afficher les astuces';
@override
String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites';
@override
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
@override
String get holdToSync => 'Appui long pour synchroniser';
@override
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
@override
String get selectPlayerHint => 'Choisissez un lecteur ou fermez en balayant vers le bas';
@override
String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
@override
String get welcomeMessage => 'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
@override
String get skip => 'Passer';
@override
String get dismissPlayerHint => 'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
@override
String playingAlbum(String albumName) {
return 'Lecture de $albumName';
}
@override
String playingPlaylist(String playlistName) {
return 'Lecture de $playlistName';
}
@override
String get noTracks => 'No tracks';
@override
String get addTracksHint => 'Add some music to your library to see tracks here';
@override
String get noFavoriteRadioStations => 'No favorite radio stations';
@override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
@override
String get noFavoritePodcasts => 'No favorite podcasts';
@override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
}
+150 -20
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -33,6 +34,10 @@ final _logger = DebugLogger();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize SharedPreferences cache early for performance
await SettingsService.initialize();
_logger.log('⚡ SharedPreferences cached');
// Initialize local database
await DatabaseService.instance.initialize();
_logger.log('💾 Database initialized');
@@ -40,6 +45,10 @@ Future<void> main() async {
// Migrate existing ownerName to profile (one-time for existing users)
await ProfileService.instance.migrateFromOwnerName();
// Migrate credentials to secure storage (one-time for existing users)
await SettingsService.migrateToSecureStorage();
_logger.log('🔐 Secure storage migration complete');
// Load library from cache for instant startup
await SyncService.instance.loadFromCache();
_logger.log('📦 Library cache loaded');
@@ -67,17 +76,42 @@ Future<void> main() async {
DeviceOrientation.portraitDown,
]);
// Set system UI overlay style
// Set initial system UI overlay style based on platform brightness
// SystemUIWrapper will update this dynamically when theme changes
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
final isDark = platformBrightness == Brightness.dark;
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Color(0xFF1a1a1a),
systemNavigationBarIconBrightness: Brightness.light,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
runApp(const MusicAssistantApp());
// Set up Flutter error boundary to catch and log widget build errors
FlutterError.onError = (FlutterErrorDetails details) {
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
// Still report to Flutter's default handler in debug mode
FlutterError.presentError(details);
};
// Catch errors from the platform dispatcher (platform channel errors)
PlatformDispatcher.instance.onError = (error, stack) {
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
return true; // Handled
};
// Wrap app in zone to catch async errors
runZonedGuarded(
() => runApp(const MusicAssistantApp()),
(error, stackTrace) {
_logger.error('Uncaught async error: $error', context: 'ZoneError');
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
},
);
}
class MusicAssistantApp extends StatefulWidget {
@@ -172,11 +206,28 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
@override
Future<bool> didPopRoute() async {
// Intercept back button at app level - runs BEFORE Navigator processes it
// If player is expanded, collapse it and consume the back gesture
// Priority order: device list > queue panel > expanded player > normal navigation
// Device list has highest priority - dismiss it first
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
GlobalPlayerOverlay.dismissPlayerReveal();
return true; // We handled it, don't let Navigator process it
}
// Queue panel second - close it before collapsing player
// Use target state (not animation value) to handle rapid open-close timing
// Use withHaptic: false because Android back gesture provides system haptic
if (GlobalPlayerOverlay.isQueuePanelTargetOpen) {
GlobalPlayerOverlay.closeQueuePanel(withHaptic: false);
return true; // We handled it, don't let Navigator process it
}
// Then check expanded player
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
return true; // We handled it, don't let Navigator process it
}
// Let Navigator handle the back gesture normally
return super.didPopRoute();
}
@@ -216,14 +267,21 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
ColorScheme? darkColorScheme;
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
// Use system color schemes
// Use system color schemes, but override background to match app design
// Material You doesn't set background consistently, causing black screen issues
final (light, dark) = snapshot.data!;
lightColorScheme = light;
darkColorScheme = dark;
lightColorScheme = light.copyWith(
surface: light.surface,
background: const Color(0xFFFAFAFA), // App's preferred light background
);
darkColorScheme = dark.copyWith(
surface: const Color(0xFF2a2a2a), // App's preferred dark surface
background: const Color(0xFF1a1a1a), // App's preferred dark background
);
} else {
// Use brand color schemes
lightColorScheme = brandLightColorScheme;
darkColorScheme = brandDarkColorScheme;
// Use custom color from theme provider
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
darkColorScheme = generateDarkColorScheme(themeProvider.customColor);
}
return SystemUIWrapper(
@@ -243,6 +301,29 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
],
supportedLocales: S.supportedLocales,
locale: localeProvider.locale,
// Fallback to English when locale is not supported
localeListResolutionCallback: (locales, supportedLocales) {
// If user has set a specific locale, try to use it
if (localeProvider.locale != null) {
for (final supported in supportedLocales) {
if (supported.languageCode == localeProvider.locale!.languageCode) {
return supported;
}
}
}
// Try to match system locales
if (locales != null) {
for (final locale in locales) {
for (final supported in supportedLocales) {
if (supported.languageCode == locale.languageCode) {
return supported;
}
}
}
}
// Fallback to English (not German)
return const Locale('en');
},
themeMode: themeProvider.themeMode,
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
@@ -319,8 +400,8 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark
? widget.darkColorScheme.background
: widget.lightColorScheme.background,
? widget.darkColorScheme.surface
: widget.lightColorScheme.surface,
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
@@ -339,7 +420,16 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
}
}
/// Startup widget that checks if user is logged in and auto-connects
/// Startup widget that checks if user is logged in and auto-connects.
///
/// CRITICAL: This widget is the single source of truth for the startup transition.
/// It loads settings FIRST (before connection), then waits for the appropriate
/// conditions before showing HomeScreen. This eliminates the race condition between
/// AppStartup and GlobalPlayerOverlay that caused the home screen flash.
///
/// For first-time users: Holds dark screen until connected + player selected,
/// so the welcome overlay can appear seamlessly.
/// For returning users: Transitions as soon as connection is established.
class AppStartup extends StatefulWidget {
const AppStartup({super.key});
@@ -352,9 +442,32 @@ class _AppStartupState extends State<AppStartup> {
String? _savedServerUrl;
bool _connectionAttempted = false;
// Welcome system state - loaded FIRST before connection check
bool _settingsLoaded = false;
bool _hasCompletedOnboarding = true; // Default to true (no welcome needed)
@override
void initState() {
super.initState();
_loadSettingsFirst();
}
/// Load settings BEFORE checking connection.
/// This ensures we know if this is a first-time user before transitioning.
Future<void> _loadSettingsFirst() async {
// Load onboarding state first - this is critical for determining
// when to transition to HomeScreen
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!mounted) return;
setState(() {
_settingsLoaded = true;
});
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
// Now check connection
_checkAndConnect();
}
@@ -379,14 +492,30 @@ class _AppStartupState extends State<AppStartup> {
// Just wait for it to complete or timeout
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
// For first-time users, we need to wait for both connection AND player selection
// so the welcome overlay can appear immediately without home screen flash.
// For returning users, just wait for connection.
final needsWelcome = !_hasCompletedOnboarding;
// Give the provider time to connect (it restores credentials and connects in _initialize)
// Check connection state periodically
for (var i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250));
if (provider.isConnected) {
_logger.log('🚀 AppStartup: Connection established');
break;
if (needsWelcome) {
// First-time user: wait for connection + player selection
if (provider.isConnected && provider.selectedPlayer != null) {
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
break;
}
} else {
// Returning user: just wait for connection
if (provider.isConnected) {
_logger.log('🚀 AppStartup: Connection established');
break;
}
}
if (provider.connectionState == MAConnectionState.error) {
_logger.log('🚀 AppStartup: Connection failed with error');
break;
@@ -413,7 +542,8 @@ class _AppStartupState extends State<AppStartup> {
@override
Widget build(BuildContext context) {
// Show loading while checking settings or connecting
if (!_connectionAttempted || _isConnecting) {
// CRITICAL: Don't transition until settings are loaded - this prevents the race
if (!_settingsLoaded || !_connectionAttempted || _isConnecting) {
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
body: Center(
+65 -1
View File
@@ -58,6 +58,10 @@ class MediaItem {
final bool? favorite;
final int? position;
final Duration? duration;
final DateTime? timestampAdded;
final DateTime? timestampModified;
final DateTime? lastPlayed;
final int? playCount;
MediaItem({
required this.itemId,
@@ -71,6 +75,10 @@ class MediaItem {
this.favorite,
this.position,
this.duration,
this.timestampAdded,
this.timestampModified,
this.lastPlayed,
this.playCount,
});
factory MediaItem.fromJson(Map<String, dynamic> json) {
@@ -83,6 +91,21 @@ class MediaItem {
);
}
// Parse timestamp fields (can be int unix timestamp or ISO string)
DateTime? parseTimestamp(dynamic value) {
if (value == null) return null;
if (value is int) {
return DateTime.fromMillisecondsSinceEpoch(value * 1000);
}
if (value is double) {
return DateTime.fromMillisecondsSinceEpoch((value * 1000).toInt());
}
if (value is String) {
return DateTime.tryParse(value);
}
return null;
}
return MediaItem(
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
provider: json['provider'] as String? ?? 'unknown',
@@ -99,6 +122,10 @@ class MediaItem {
duration: json['duration'] != null
? Duration(seconds: (json['duration'] as num).toInt())
: null,
timestampAdded: parseTimestamp(json['timestamp_added']),
timestampModified: parseTimestamp(json['timestamp_modified']),
lastPlayed: parseTimestamp(json['last_played']),
playCount: json['play_count'] as int?,
);
}
@@ -110,11 +137,16 @@ class MediaItem {
'media_type': mediaType.name,
if (sortName != null) 'sort_name': sortName,
if (uri != null) 'uri': uri,
if (providerMappings != null) 'provider_mappings': providerMappings,
if (providerMappings != null)
'provider_mappings': providerMappings!.map((m) => m.toJson()).toList(),
if (metadata != null) 'metadata': metadata,
if (favorite != null) 'favorite': favorite,
if (position != null) 'position': position,
if (duration != null) 'duration': duration!.inSeconds,
if (timestampAdded != null) 'timestamp_added': timestampAdded!.millisecondsSinceEpoch ~/ 1000,
if (timestampModified != null) 'timestamp_modified': timestampModified!.millisecondsSinceEpoch ~/ 1000,
if (lastPlayed != null) 'last_played': lastPlayed!.millisecondsSinceEpoch ~/ 1000,
if (playCount != null) 'play_count': playCount,
};
}
}
@@ -129,6 +161,10 @@ class Artist extends MediaItem {
super.providerMappings,
super.metadata,
super.favorite,
super.timestampAdded,
super.timestampModified,
super.lastPlayed,
super.playCount,
}) : super(mediaType: MediaType.artist);
factory Artist.fromJson(Map<String, dynamic> json) {
@@ -142,6 +178,10 @@ class Artist extends MediaItem {
providerMappings: item.providerMappings,
metadata: item.metadata,
favorite: item.favorite,
timestampAdded: item.timestampAdded,
timestampModified: item.timestampModified,
lastPlayed: item.lastPlayed,
playCount: item.playCount,
);
}
@@ -166,6 +206,10 @@ class Album extends MediaItem {
super.providerMappings,
super.metadata,
super.favorite,
super.timestampAdded,
super.timestampModified,
super.lastPlayed,
super.playCount,
}) : super(mediaType: MediaType.album);
factory Album.fromJson(Map<String, dynamic> json) {
@@ -193,6 +237,10 @@ class Album extends MediaItem {
providerMappings: item.providerMappings,
metadata: item.metadata,
favorite: item.favorite,
timestampAdded: item.timestampAdded,
timestampModified: item.timestampModified,
lastPlayed: item.lastPlayed,
playCount: item.playCount,
);
}
@@ -239,6 +287,10 @@ class Track extends MediaItem {
super.favorite,
super.position,
super.duration,
super.timestampAdded,
super.timestampModified,
super.lastPlayed,
super.playCount,
}) : super(mediaType: MediaType.track);
factory Track.fromJson(Map<String, dynamic> json) {
@@ -260,6 +312,10 @@ class Track extends MediaItem {
favorite: item.favorite,
position: item.position,
duration: item.duration,
timestampAdded: item.timestampAdded,
timestampModified: item.timestampModified,
lastPlayed: item.lastPlayed,
playCount: item.playCount,
);
}
@@ -294,6 +350,10 @@ class Playlist extends MediaItem {
super.providerMappings,
super.metadata,
super.favorite,
super.timestampAdded,
super.timestampModified,
super.lastPlayed,
super.playCount,
}) : super(mediaType: MediaType.playlist);
factory Playlist.fromJson(Map<String, dynamic> json) {
@@ -310,6 +370,10 @@ class Playlist extends MediaItem {
providerMappings: item.providerMappings,
metadata: item.metadata,
favorite: item.favorite,
timestampAdded: item.timestampAdded,
timestampModified: item.timestampModified,
lastPlayed: item.lastPlayed,
playCount: item.playCount,
);
}
}
+123 -7
View File
@@ -12,6 +12,11 @@ class Player {
final bool? volumeMuted;
final double? elapsedTime; // Seconds elapsed in current track
final double? elapsedTimeLastUpdated; // Unix timestamp when elapsed_time was last updated
final List<String>? groupMembers; // List of player IDs in sync group (includes self)
final String? syncedTo; // Player ID this player is synced to (null if leader or not synced)
final String? activeSource; // The currently active source for this player
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
Player({
required this.playerId,
@@ -25,6 +30,11 @@ class Player {
this.volumeMuted,
this.elapsedTime,
this.elapsedTimeLastUpdated,
this.groupMembers,
this.syncedTo,
this.activeSource,
this.isExternalSource = false,
this.appId,
});
/// Create a copy of this Player with some fields replaced
@@ -40,6 +50,11 @@ class Player {
bool? volumeMuted,
double? elapsedTime,
double? elapsedTimeLastUpdated,
List<String>? groupMembers,
String? syncedTo,
String? activeSource,
bool? isExternalSource,
String? appId,
}) {
return Player(
playerId: playerId ?? this.playerId,
@@ -53,6 +68,11 @@ class Player {
volumeMuted: volumeMuted ?? this.volumeMuted,
elapsedTime: elapsedTime ?? this.elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
groupMembers: groupMembers ?? this.groupMembers,
syncedTo: syncedTo ?? this.syncedTo,
activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource,
appId: appId ?? this.appId,
);
}
@@ -61,8 +81,37 @@ class Player {
bool get isMuted => volumeMuted ?? false;
int get volume => volumeLevel ?? 0;
// Group properties
// A player is grouped if it's a leader with members OR a child synced to another
bool get isGrouped => (groupMembers != null && groupMembers!.length > 1) || syncedTo != null;
bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && syncedTo == null;
bool get isGroupChild => syncedTo != null;
// A player is manually synced if it's synced TO another player (child of a sync group)
// This excludes pre-configured MA speaker groups which have groupMembers but no syncedTo
// Used for yellow border highlight - only shows for players the user manually synced
bool get isManuallySynced => syncedTo != null;
// Track when this Player object was created (for local interpolation fallback)
static final Map<String, double> _playerCreationTimes = {};
static const int _maxCreationTimesEntries = 50;
/// Clean up old creation time entries using LRU eviction
static void _cleanupCreationTimes() {
if (_playerCreationTimes.length <= _maxCreationTimesEntries) return;
// Sort by timestamp value (oldest first) for true LRU eviction
final sortedEntries = _playerCreationTimes.entries.toList()
..sort((a, b) => a.value.compareTo(b.value));
// Remove oldest entries until we're at target size
final entriesToRemove = sortedEntries.take(
_playerCreationTimes.length - _maxCreationTimesEntries,
);
for (final entry in entriesToRemove) {
_playerCreationTimes.remove(entry.key);
}
}
// Calculate current elapsed time (interpolated if playing)
double get currentElapsedTime {
@@ -104,13 +153,8 @@ class Player {
if (!_playerCreationTimes.containsKey(creationKey)) {
// First time seeing this elapsed_time - record when we saw it
_playerCreationTimes[creationKey] = now;
// Clean up old entries to prevent memory leak
if (_playerCreationTimes.length > 100) {
final keysToRemove = _playerCreationTimes.keys.take(50).toList();
for (final key in keysToRemove) {
_playerCreationTimes.remove(key);
}
}
// Clean up old entries to prevent memory leak (LRU eviction)
_cleanupCreationTimes();
}
final creationTime = _playerCreationTimes[creationKey]!;
@@ -164,6 +208,68 @@ class Player {
elapsedTime ??= topLevelElapsedTime;
elapsedTimeLastUpdated ??= topLevelLastUpdated;
// Parse group members - MA returns this as a list of player IDs
final groupMembersList = json['group_members'] as List<dynamic>?;
final groupMembers = groupMembersList?.map((e) => e.toString()).toList();
// Parse synced_to - the player ID this player is synced to
final syncedTo = json['synced_to'] as String?;
// Parse active_source - the currently active source for this player
final activeSource = json['active_source'] as String?;
// Detect external source (optical, Spotify, AirPlay, etc.)
// Primary indicator: app_id - if not 'music_assistant', it's an external source
// Secondary indicators: URI patterns and media_type
bool isExternalSource = false;
// Check app_id first - this is the most reliable indicator for DLNA players
// When MA is playing: app_id = 'music_assistant'
// When external source: app_id = 'http', 'spotify', etc.
final appId = json['app_id'] as String?;
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
// app_id is set but not 'music_assistant' - external source is active
isExternalSource = true;
}
// Also check current_media for additional external source indicators
if (!isExternalSource && json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) {
final uri = currentMedia['uri'] as String?;
final mediaType = currentMedia['media_type'] as String?;
// External source indicators:
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
// 3. Media type is 'unknown' (MA doesn't recognize the source)
if (uri != null) {
final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /)
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink');
// External streaming protocols
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId || isExternalProtocol;
}
// Also check media_type - 'unknown' often indicates external source
if (!isExternalSource && mediaType == 'unknown') {
// If media_type is unknown and URI doesn't look like MA content, it's external
final uri = currentMedia['uri'] as String?;
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
isExternalSource = true;
}
}
}
}
return Player(
playerId: json['player_id'] as String,
name: json['name'] as String,
@@ -176,6 +282,11 @@ class Player {
volumeMuted: json['volume_muted'] as bool?,
elapsedTime: elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
groupMembers: groupMembers,
syncedTo: syncedTo,
activeSource: activeSource,
isExternalSource: isExternalSource,
appId: appId,
);
}
@@ -192,6 +303,11 @@ class Player {
if (volumeMuted != null) 'volume_muted': volumeMuted,
if (elapsedTime != null) 'elapsed_time': elapsedTime,
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
if (groupMembers != null) 'group_members': groupMembers,
if (syncedTo != null) 'synced_to': syncedTo,
if (activeSource != null) 'active_source': activeSource,
'is_external_source': isExternalSource,
if (appId != null) 'app_id': appId,
};
}
}
-250
View File
@@ -1,250 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/database_service.dart';
import '../services/profile_service.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/auth/auth_manager.dart';
import '../services/cache_service.dart';
/// Provider for managing connection state to Music Assistant server
/// Handles WebSocket connection, authentication, and reconnection
class ConnectionProvider with ChangeNotifier {
MusicAssistantAPI? _api;
final AuthManager _authManager = AuthManager();
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService = CacheService();
MAConnectionState _connectionState = MAConnectionState.disconnected;
String? _serverUrl;
String? _error;
// Callbacks for post-connection initialization
VoidCallback? onConnected;
VoidCallback? onAuthenticated;
VoidCallback? onDisconnected;
MAConnectionState get connectionState => _connectionState;
String? get serverUrl => _serverUrl;
String? get error => _error;
bool get isConnected => _connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
MusicAssistantAPI? get api => _api;
AuthManager get authManager => _authManager;
CacheService get cacheService => _cacheService;
ConnectionProvider() {
_initialize();
}
Future<void> _initialize() async {
_serverUrl = await SettingsService.getServerUrl();
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
await _restoreAuthCredentials();
await connectToServer(_serverUrl!);
}
}
/// Restore auth credentials from persistent storage
Future<void> _restoreAuthCredentials() async {
final savedCredentials = await SettingsService.getAuthCredentials();
if (savedCredentials != null) {
_logger.log('🔐 Restoring saved auth credentials...');
_authManager.deserializeCredentials(savedCredentials);
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}');
} else {
_logger.log('🔐 No saved auth credentials found');
}
}
/// Handle Music Assistant native authentication after WebSocket connection
Future<bool> handleMaAuthentication() async {
if (_api == null) return false;
try {
// First, try stored MA token
final storedToken = await SettingsService.getMaAuthToken();
if (storedToken != null) {
_logger.log('🔐 Trying stored MA token...');
final success = await _api!.authenticateWithToken(storedToken);
if (success) {
_logger.log('✅ MA authentication with stored token successful');
await _fetchAndSetUserProfileName();
return true;
}
_logger.log('⚠️ Stored MA token invalid, clearing...');
await SettingsService.clearMaAuthToken();
}
// No valid token - try stored credentials
final username = await SettingsService.getUsername();
final password = await SettingsService.getPassword();
if (username != null && password != null && username.isNotEmpty && password.isNotEmpty) {
_logger.log('🔐 Trying stored credentials...');
final accessToken = await _api!.loginWithCredentials(username, password);
if (accessToken != null) {
_logger.log('✅ MA login with stored credentials successful');
final longLivedToken = await _api!.createLongLivedToken();
if (longLivedToken != null) {
await SettingsService.setMaAuthToken(longLivedToken);
_logger.log('✅ Saved new long-lived MA token');
} else {
await SettingsService.setMaAuthToken(accessToken);
}
await _fetchAndSetUserProfileName();
return true;
}
}
_logger.log('❌ MA authentication failed - no valid token or credentials');
return false;
} catch (e) {
_logger.log('❌ MA authentication error: $e');
return false;
}
}
/// Fetch user profile from MA and set owner name + create/activate local profile
Future<void> _fetchAndSetUserProfileName() async {
if (_api == null) return;
try {
final userInfo = await _api!.getCurrentUserInfo();
if (userInfo == null) {
_logger.log('⚠️ Could not fetch user profile');
return;
}
final displayName = userInfo['display_name'] as String?;
final username = userInfo['username'] as String?;
_logger.log('🔍 Profile data: display_name="$displayName", username="$username"');
final profileName = (displayName != null && displayName.isNotEmpty)
? displayName
: username;
if (profileName != null && profileName.isNotEmpty) {
await SettingsService.setOwnerName(profileName);
_logger.log('✅ Set owner name from MA profile: $profileName');
// Create/activate profile in local database
if (DatabaseService.instance.isInitialized && username != null) {
await ProfileService.instance.onMaAuthenticated(
username: username,
displayName: displayName,
);
_logger.log('✅ Local profile activated: $username');
}
} else {
_logger.log('⚠️ No valid name in profile');
}
} catch (e) {
_logger.log('⚠️ Could not fetch user profile (non-fatal): $e');
}
}
Future<void> connectToServer(String serverUrl) async {
try {
_error = null;
_serverUrl = serverUrl;
await SettingsService.setServerUrl(serverUrl);
// Disconnect existing connection
await _api?.disconnect();
_api = MusicAssistantAPI(serverUrl, _authManager);
// Listen to connection state changes
_api!.connectionState.listen(
(state) async {
_connectionState = state;
notifyListeners();
if (state == MAConnectionState.connected) {
_logger.log('🔗 WebSocket connected to MA server');
if (_api!.authRequired && !_api!.isAuthenticated) {
_logger.log('🔐 MA auth required, attempting authentication...');
final authenticated = await handleMaAuthentication();
if (!authenticated) {
_logger.log('❌ MA authentication failed - stopping connection flow');
_error = 'Authentication required. Please log in again.';
notifyListeners();
return;
}
return;
}
// No auth required - notify connected
onConnected?.call();
} else if (state == MAConnectionState.authenticated) {
_logger.log('✅ MA authentication successful');
onAuthenticated?.call();
} else if (state == MAConnectionState.disconnected) {
_cacheService.clearAll();
onDisconnected?.call();
}
},
onError: (error) {
_logger.log('Connection state stream error: $error');
_connectionState = MAConnectionState.error;
notifyListeners();
},
);
await _api!.connect();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Connect to server');
_error = errorInfo.userMessage;
_connectionState = MAConnectionState.error;
_logger.log('Connection error: ${errorInfo.technicalMessage}');
notifyListeners();
rethrow;
}
}
Future<void> disconnect() async {
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
_cacheService.clearAll();
notifyListeners();
}
/// Check connection and reconnect if needed (called when app resumes)
Future<void> checkAndReconnect() async {
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
if (_serverUrl == null) {
_logger.log('🔄 No server URL saved, skipping reconnect');
return;
}
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
try {
await connectToServer(_serverUrl!);
_logger.log('🔄 Reconnection successful');
} catch (e) {
_logger.log('🔄 Reconnection failed: $e');
}
} else {
_logger.log('🔄 Already connected, connection verified');
}
}
@override
void dispose() {
_api?.dispose();
super.dispose();
}
}
-383
View File
@@ -1,383 +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 '../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();
final results = await Future.wait([
_api!.getArtists(limit: LibraryConstants.maxLibraryItems),
_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();
_artists = await _api!.getArtists(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
);
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId}) async {
if (_api == null) return;
try {
_isLoading = true;
_error = null;
notifyListeners();
_albums = await _api!.getAlbums(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
artistId: artistId,
);
_isLoading = false;
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
_error = errorInfo.userMessage;
_isLoading = false;
notifyListeners();
}
}
// ============================================================================
// DETAIL SCREEN DATA
// ============================================================================
/// Get album tracks with caching
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached album tracks for $cacheKey');
return _cacheService.getCachedAlbumTracks(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching fresh album tracks for $cacheKey...');
final tracks = await _api!.getAlbumTracks(provider, itemId);
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch album tracks: $e');
return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
}
}
/// Get playlist tracks with caching
Future<List<Track>> getPlaylistTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isPlaylistTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached playlist tracks for $cacheKey');
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
final tracks = await _api!.getPlaylistTracks(provider, itemId);
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch playlist tracks: $e');
return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
}
}
/// Get artist albums with caching
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
final cacheKey = artistName.toLowerCase();
if (_cacheService.isArtistAlbumsCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached artist albums for "$artistName"');
return _cacheService.getCachedArtistAlbums(cacheKey)!;
}
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching albums for artist "$artistName"...');
final libraryAlbums = await _api!.getAlbums();
final artistAlbums = libraryAlbums.where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
}).toList();
final searchResults = await _api!.search(artistName);
final searchAlbums = searchResults['albums'] as List<MediaItem>? ?? [];
final providerAlbums = searchAlbums.whereType<Album>().where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
}).toList();
final allAlbums = <Album>[];
final seenNames = <String>{};
for (final album in [...artistAlbums, ...providerAlbums]) {
final key = album.name.toLowerCase();
if (!seenNames.contains(key)) {
seenNames.add(key);
allAlbums.add(album);
}
}
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
return allAlbums;
} catch (e) {
_logger.log('❌ Failed to fetch artist albums: $e');
return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
}
}
/// Invalidate album tracks cache
void invalidateAlbumTracksCache(String albumId) {
_cacheService.invalidateAlbumTracksCache(albumId);
}
/// Invalidate playlist tracks cache
void invalidatePlaylistTracksCache(String playlistId) {
_cacheService.invalidatePlaylistTracksCache(playlistId);
}
// ============================================================================
// SEARCH
// ============================================================================
/// Search with caching
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
final cacheKey = query.toLowerCase().trim();
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached search results for "$query"');
return _cacheService.getCachedSearchResults(cacheKey)!;
}
if (_api == null) {
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
}
try {
_logger.log('🔄 Searching for "$query"...');
final results = await _api!.search(query);
final cachedResults = <String, List<MediaItem>>{
'artists': results['artists'] ?? [],
'albums': results['albums'] ?? [],
'tracks': results['tracks'] ?? [],
};
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
return cachedResults;
} catch (e) {
_logger.log('❌ Search failed: $e');
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
}
}
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
if (_api == null) return {'artists': [], 'albums': [], 'tracks': []};
try {
return await _api!.search(query, libraryOnly: libraryOnly);
} catch (e) {
ErrorHandler.logError('Search', e);
return {'artists': [], 'albums': [], 'tracks': []};
}
}
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
if (_api == null) return [];
try {
return await _api!.getAlbumTracks(provider, itemId);
} catch (e) {
ErrorHandler.logError('Get album tracks', e);
return [];
}
}
void clearState() {
_artists = [];
_albums = [];
_tracks = [];
_isLoading = false;
_error = null;
clearSearchState();
notifyListeners();
}
}
+4
View File
@@ -31,6 +31,10 @@ class LocaleProvider extends ChangeNotifier {
return 'English';
case 'de':
return 'Deutsch';
case 'es':
return 'Español';
case 'fr':
return 'Français';
case null:
return 'System';
default:
File diff suppressed because it is too large Load Diff
-963
View File
@@ -1,963 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.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();
if (lastSelectedPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == lastSelectedPlayerId && p.available,
);
} catch (e) {}
}
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
} catch (e) {}
}
if (playerToSelect == null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.state == 'playing' && p.available,
);
} catch (e) {}
}
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) async {
if (_api == null) return;
try {
await _api!.toggleShuffle(queueId);
} 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();
}
}
+361 -200
View File
@@ -10,17 +10,22 @@ import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'artist_details_screen.dart';
class AlbumDetailsScreen extends StatefulWidget {
final Album album;
final String? heroTagSuffix;
/// Initial image URL from the source (e.g., AlbumCard) for seamless hero animation
final String? initialImageUrl;
const AlbumDetailsScreen({
super.key,
required this.album,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
@@ -32,11 +37,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
List<Track> _tracks = [];
bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
bool _isDescriptionExpanded = false;
String? _albumDescription;
Album? _freshAlbum; // Full album data with image metadata
/// Get the best album data available (fresh with images, or widget.album as fallback)
Album get _displayAlbum => _freshAlbum ?? widget.album;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -44,31 +54,34 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void initState() {
super.initState();
_isFavorite = widget.album.favorite ?? false;
_isInLibrary = widget.album.inLibrary;
_loadTracks();
_loadAlbumDescription();
_refreshFavoriteStatus();
// CRITICAL FIX: Defer color extraction until after the transition completes
// This prevents expensive palette extraction during hero animation
// Wait for the first frame to be painted, then extract colors
// CRITICAL FIX: Defer both fresh data loading AND color extraction until
// after the hero animation completes. This prevents:
// 1. setState with new image URL during animation → grey icon flash
// 2. Expensive palette extraction blocking animation frames
WidgetsBinding.instance.addPostFrameCallback((_) {
// Add small delay to ensure transition is complete
// 350ms matches FadeSlidePageRoute duration (300ms) + buffer
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_loadFreshAlbumData();
_extractColors();
}
});
});
}
Future<void> _refreshFavoriteStatus() async {
/// Load fresh album data from API to get full metadata including images
Future<void> _loadFreshAlbumData() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
// Need a URI to fetch fresh album data
final albumUri = widget.album.uri;
if (albumUri == null || albumUri.isEmpty) {
_logger.log('Cannot refresh favorite status: album has no URI');
_logger.log('Cannot load fresh album: album has no URI');
return;
}
@@ -76,17 +89,20 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final freshAlbum = await maProvider.api!.getAlbumByUri(albumUri);
if (freshAlbum != null && mounted) {
setState(() {
_freshAlbum = freshAlbum;
_isFavorite = freshAlbum.favorite ?? false;
});
// Re-extract colors now that we have fresh album with images
_extractColors();
}
} catch (e) {
_logger.log('Error refreshing favorite status: $e');
_logger.log('Error loading fresh album data: $e');
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
final imageUrl = maProvider.getImageUrl(_displayAlbum, size: 512);
if (imageUrl == null) return;
@@ -108,10 +124,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
final newState = !_isFavorite;
bool success;
if (newState) {
// For adding: use the actual provider and itemId from provider_mappings
@@ -127,22 +143,24 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
orElse: () => widget.album.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
_logger.log('Adding to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('album', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'album',
itemId: actualItemId,
provider: actualProvider,
);
} else {
// For removing: need the library_item_id (numeric)
// When provider is "library", itemId is the library ID as a string
// Otherwise, we need to find it from provider_mappings
int? libraryItemId;
if (widget.album.provider == 'library') {
libraryItemId = int.tryParse(widget.album.itemId);
} else if (widget.album.providerMappings != null) {
// Find the library mapping
final libraryMapping = widget.album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.album.providerMappings!.first,
@@ -157,26 +175,34 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
throw Exception('Could not determine library ID for this album');
}
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('album', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'album',
libraryItemId: libraryItemId,
);
}
setState(() {
_isFavorite = newState;
});
if (success) {
// Optimistically update UI regardless of online/offline state
setState(() {
_isFavorite = newState;
});
// Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache();
// Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
duration: const Duration(seconds: 1),
),
);
);
}
}
} catch (e) {
_logger.log('Error toggling favorite: $e');
@@ -191,16 +217,114 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
String? actualItemId;
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
// For adding to library, we MUST use a non-library provider
final nonLibraryMapping = widget.album.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
// Fallback to item's own provider if no non-library mapping found
if (actualProvider == null || actualItemId == null) {
if (widget.album.provider != 'library') {
actualProvider = widget.album.provider;
actualItemId = widget.album.itemId;
} else {
// Item is library-only, can't add
_logger.log('Cannot add to library: album is library-only');
return;
}
}
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
mediaType: 'album',
provider: actualProvider,
itemId: actualItemId,
);
} else {
// Remove from library
int? libraryItemId;
if (widget.album.provider == 'library') {
libraryItemId = int.tryParse(widget.album.itemId);
} else if (widget.album.providerMappings != null) {
final libraryMapping = widget.album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.album.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Cannot remove from library: no library ID found');
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling album library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update library: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
Future<void> _toggleTrackFavorite(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
final track = _tracks[trackIndex];
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
final currentFavorite = track.favorite ?? false;
try {
bool success;
if (currentFavorite) {
// Remove from favorites - need library_item_id
int? libraryItemId;
@@ -217,7 +341,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
if (libraryItemId != null) {
await maProvider.api!.removeFromFavorites('track', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'track',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
} else {
// Add to favorites
@@ -232,37 +361,47 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
orElse: () => track.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'track',
itemId: actualItemId,
provider: actualProvider,
);
}
// Update local state - create new track with toggled favorite
setState(() {
_tracks[trackIndex] = Track(
itemId: track.itemId,
provider: track.provider,
name: track.name,
uri: track.uri,
favorite: !currentFavorite,
artists: track.artists,
album: track.album,
duration: track.duration,
providerMappings: track.providerMappings,
);
});
if (success) {
// Optimistically update local state
setState(() {
_tracks[trackIndex] = Track(
itemId: track.itemId,
provider: track.provider,
name: track.name,
uri: track.uri,
favorite: !currentFavorite,
artists: track.artists,
album: track.album,
duration: track.duration,
providerMappings: track.providerMappings,
);
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
duration: const Duration(seconds: 1),
),
);
);
}
}
} catch (e) {
_logger.log('Error toggling track favorite: $e');
@@ -279,16 +418,46 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _loadTracks() async {
final provider = context.read<MusicAssistantProvider>();
final tracks = await provider.getAlbumTracksWithCache(
widget.album.provider,
widget.album.itemId,
);
final cacheKey = '${widget.album.provider}_${widget.album.itemId}';
if (mounted) {
setState(() {
_tracks = tracks;
_isLoading = false;
});
// 1. Show cached data immediately (if available)
final cachedTracks = provider.getCachedAlbumTracks(cacheKey);
if (cachedTracks != null && cachedTracks.isNotEmpty) {
if (mounted) {
setState(() {
_tracks = cachedTracks;
_isLoading = false;
});
}
}
// 2. Fetch fresh data in background (silent refresh)
try {
final freshTracks = await provider.getAlbumTracksWithCache(
widget.album.provider,
widget.album.itemId,
forceRefresh: cachedTracks != null, // Force refresh if we had cache
);
// 3. Update if we got different data
if (mounted && freshTracks.isNotEmpty) {
final tracksChanged = _tracks.length != freshTracks.length ||
(_tracks.isNotEmpty && freshTracks.isNotEmpty &&
_tracks.first.itemId != freshTracks.first.itemId);
if (tracksChanged || _tracks.isEmpty) {
setState(() {
_tracks = freshTracks;
_isLoading = false;
});
}
}
} catch (e) {
_logger.log('⚠️ Background refresh failed: $e');
}
// Ensure loading is false even if everything failed
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
@@ -361,12 +530,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.addAlbumToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -441,12 +610,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -492,7 +661,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
},
),
),
const SizedBox(height: 16),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
@@ -595,9 +764,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
// Only rebuild when specific properties change, not on every provider update
final imageUrl = context.select<MusicAssistantProvider, String?>(
(provider) => provider.getImageUrl(widget.album, size: 512),
// Use _displayAlbum which has fresh data with images if available
final providerImageUrl = context.select<MusicAssistantProvider, String?>(
(provider) => provider.getImageUrl(_displayAlbum, size: 512),
);
// Use initialImageUrl as fallback for seamless hero animation
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
@@ -634,10 +806,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
},
child: Scaffold(
backgroundColor: colorScheme.background,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 350, // Increased height for bigger art
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.background,
leading: IconButton(
@@ -655,35 +833,50 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
const SizedBox(height: 60),
GestureDetector(
onTap: () => _showFullscreenArt(imageUrl),
child: Hero(
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
child: Container(
width: 280, // Increased size
height: 280,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(16), // Slightly more rounded
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
// Shadow container (outside Hero for correct clipping)
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Hero(
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
// FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
child: imageUrl == null
? Icon(
Icons.album_rounded,
size: 120,
color: colorScheme.onSurfaceVariant,
)
: null,
),
),
),
@@ -693,7 +886,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -710,7 +903,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
Hero(
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
child: Material(
@@ -730,7 +923,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(height: 16),
Spacing.vGap16,
// Album Description
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
InkWell(
@@ -752,7 +945,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
],
Row(
children: [
@@ -823,7 +1016,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25), // Circular
borderRadius: BorderRadius.circular(Radii.xxl), // Circular
),
),
child: Icon(
@@ -834,17 +1027,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(width: 12),
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 24),
Text(
S.of(context)!.tracks,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
],
),
),
@@ -986,7 +1194,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -1009,120 +1217,73 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
childCount: _tracks.length,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
),
);
},
),
),
);
}
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
_showPlayOnSheet(
context,
players,
onPlayerSelected: (player) {
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.playTracks(
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: startIndex,
);
},
);
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
final track = _tracks[trackIndex];
_showPlayOnSheet(
context,
players,
onPlayerSelected: (player) {
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.playRadio(player.playerId, track);
await maProvider.playRadio(player.playerId, track);
},
);
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
_showPlayOnSheet(
context,
players,
onPlayerSelected: (player) {
maProvider.selectPlayer(player);
maProvider.playTracks(player.playerId, _tracks);
},
);
}
/// Shared "Play on..." bottom sheet with fixed height and scrollable list
void _showPlayOnSheet(
BuildContext context,
List players, {
required void Function(dynamic player) onPlayerSelected,
}) {
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
final screenHeight = MediaQuery.of(context).size.height;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final sheetHeight = (screenHeight * 0.45) + bottomPadding; // Fixed 45% + safe area
showModalBottomSheet(
showPlayerPickerSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => Container(
height: sheetHeight,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
const SizedBox(height: 16),
Text(
S.of(context)!.playOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: players.isEmpty
? Center(
child: Text(S.of(context)!.noPlayersAvailable),
)
: ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16),
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: () {
Navigator.pop(context);
onPlayerSelected(player);
},
);
},
),
),
],
),
),
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
await maProvider.playTracks(player.playerId, _tracks);
},
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
+386 -135
View File
@@ -13,6 +13,7 @@ import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class ArtistDetailsScreen extends StatefulWidget {
final Artist artist;
@@ -36,11 +37,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
List<Album> _providerAlbums = [];
bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
bool _isDescriptionExpanded = false;
String? _artistDescription;
String? _artistImageUrl;
MusicAssistantProvider? _maProvider;
// View preferences
String _sortOrder = 'alpha'; // 'alpha' or 'year'
@@ -52,6 +55,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
void initState() {
super.initState();
_isFavorite = widget.artist.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.artist);
// Use initial image URL immediately for smooth hero animation
_artistImageUrl = widget.initialImageUrl;
_loadViewPreferences();
@@ -66,11 +70,60 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
_loadArtistImage();
// Note: _extractColors is called by _loadArtistImage after image loads
// CRITICAL FIX: Delay adding provider listener until AFTER Hero animation
// Adding it immediately causes rebuilds during animation (jank)
_maProvider = context.read<MusicAssistantProvider>();
_maProvider?.addListener(_onProviderChanged);
}
});
});
}
void _onProviderChanged() {
if (!mounted) return;
// Re-check library status when provider data changes
final newIsInLibrary = _checkIfInLibraryFromProvider();
if (newIsInLibrary != _isInLibrary) {
setState(() {
_isInLibrary = newIsInLibrary;
});
}
}
/// Check if artist is in library using provider's artists list
bool _checkIfInLibraryFromProvider() {
if (_maProvider == null) return _checkIfInLibrary(widget.artist);
final artistName = widget.artist.name.toLowerCase();
final artistUri = widget.artist.uri;
// Check if this artist exists in the provider's library
return _maProvider!.artists.any((a) {
// Match by URI if available
if (artistUri != null && a.uri == artistUri) return true;
// Match by name as fallback
if (a.name.toLowerCase() == artistName) return true;
// Check provider mappings for matching URIs
if (widget.artist.providerMappings != null) {
for (final mapping in widget.artist.providerMappings!) {
if (a.providerMappings?.any((m) =>
m.providerInstance == mapping.providerInstance &&
m.itemId == mapping.itemId) == true) {
return true;
}
}
}
return false;
});
}
@override
void dispose() {
_maProvider?.removeListener(_onProviderChanged);
super.dispose();
}
Future<void> _loadViewPreferences() async {
final sortOrder = await SettingsService.getArtistAlbumsSortOrder();
final viewMode = await SettingsService.getArtistAlbumsViewMode();
@@ -155,10 +208,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
final newState = !_isFavorite;
bool success;
if (newState) {
String actualProvider = widget.artist.provider;
@@ -172,12 +225,17 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
orElse: () => widget.artist.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
_logger.log('Adding artist to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('artist', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'artist',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
@@ -198,25 +256,32 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
throw Exception('Could not determine library ID for this artist');
}
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('artist', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
setState(() {
_isFavorite = newState;
});
if (success) {
setState(() {
_isFavorite = newState;
});
maProvider.invalidateHomeCache();
maProvider.invalidateHomeCache();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
duration: const Duration(seconds: 1),
),
);
);
}
}
} catch (e) {
_logger.log('Error toggling artist favorite: $e');
@@ -231,62 +296,134 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
}
Future<void> _startArtistRadio() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
/// Check if artist is in library
bool _checkIfInLibrary(Artist artist) {
if (artist.provider == 'library') return true;
return artist.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
await maProvider.playArtistRadio(selectedPlayer.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.startingRadio(widget.artist.name)),
duration: const Duration(seconds: 1),
),
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
String? actualItemId;
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
// For adding to library, we MUST use a non-library provider
final nonLibraryMapping = widget.artist.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
// Fallback to item's own provider if no non-library mapping found
if (actualProvider == null || actualItemId == null) {
if (widget.artist.provider != 'library') {
actualProvider = widget.artist.provider;
actualItemId = widget.artist.itemId;
} else {
// Item is library-only, can't add
_logger.log('Cannot add to library: artist is library-only');
return;
}
}
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
mediaType: 'artist',
provider: actualProvider,
itemId: actualItemId,
);
} else {
// Remove from library
int? libraryItemId;
if (widget.artist.provider == 'library') {
libraryItemId = int.tryParse(widget.artist.itemId);
} else if (widget.artist.providerMappings != null) {
final libraryMapping = widget.artist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.artist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Cannot remove from library: no library ID found');
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error starting artist radio: $e');
_logger.log('Error toggling artist library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
content: Text('Failed to update library: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
void _showPlayOnMenu(BuildContext context) {
void _showRadioMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => Padding(
padding: const EdgeInsets.all(16.0),
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Spacing.vGap16,
Text(
S.of(context)!.startRadioOn(widget.artist.name),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
@@ -302,7 +439,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
return ListTile(
leading: Icon(
Icons.speaker,
color: colorScheme.onSurface,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
@@ -333,46 +470,103 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
},
),
),
const SizedBox(height: 16),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
);
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
Future<void> _addArtistRadioToQueue() async {
void _showAddToQueueMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
final players = maProvider.availablePlayers;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
try {
// For adding to queue with radio mode, we use radio_mode but with 'add' option
await maProvider.api?.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(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,
),
);
}
}
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
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),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
Future<void> _loadArtistImage() async {
@@ -427,20 +621,51 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Future<void> _loadArtistAlbums() async {
final provider = context.read<MusicAssistantProvider>();
// Use cached artist albums method
final allAlbums = await provider.getArtistAlbumsWithCache(widget.artist.name);
// 1. Show cached data immediately (if available)
final cachedAlbums = provider.getCachedArtistAlbums(widget.artist.name);
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
final libraryAlbums = cachedAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = cachedAlbums.where((a) => !a.inLibrary).toList();
if (mounted) {
// Separate library albums from provider-only albums
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
if (mounted) {
setState(() {
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
_sortAlbums();
_isLoading = false;
});
}
}
setState(() {
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
_sortAlbums(); // Apply saved sort order
_isLoading = false;
});
// 2. Fetch fresh data in background (silent refresh)
try {
final allAlbums = await provider.getArtistAlbumsWithCache(
widget.artist.name,
forceRefresh: cachedAlbums != null,
);
if (mounted && allAlbums.isNotEmpty) {
// Check if data actually changed
final albumsChanged = _albums.length != allAlbums.where((a) => a.inLibrary).length;
if (albumsChanged || _albums.isEmpty) {
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
setState(() {
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
_sortAlbums();
_isLoading = false;
});
}
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
@@ -483,10 +708,16 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.background,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.background,
leading: IconButton(
@@ -506,22 +737,28 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
child: ClipOval(
child: Container(
width: 200,
height: 200,
width: coverSize,
height: coverSize,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? Image.network(
imageUrl,
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.person_rounded,
size: 100,
size: coverSize * 0.5,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.person_rounded,
size: 100,
size: coverSize * 0.5,
color: colorScheme.onSurfaceVariant,
),
),
@@ -550,7 +787,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(height: 16),
Spacing.vGap16,
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
InkWell(
onTap: () {
@@ -571,7 +808,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
],
// Action Buttons Row
Row(
@@ -582,7 +819,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _startArtistRadio,
onPressed: () => _showRadioMenu(context),
icon: const Icon(Icons.radio),
label: Text(S.of(context)!.radio),
style: ElevatedButton.styleFrom(
@@ -595,23 +832,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(width: 12),
// "Play on..." Button (Square)
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
@@ -620,7 +840,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _addArtistRadioToQueue,
onPressed: () => _showAddToQueueMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
@@ -642,7 +862,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -653,9 +873,32 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(width: 12),
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 16),
Spacing.vGap16,
],
),
),
@@ -705,7 +948,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
// View mode toggle
IconButton(
@@ -726,7 +969,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
),
@@ -762,7 +1005,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
IconButton(
icon: Icon(
@@ -782,7 +1025,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
],
@@ -790,10 +1033,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
_buildAlbumSliver(_providerAlbums),
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
],
],
);
},
),
),
);
@@ -817,6 +1062,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: AlbumDetailsScreen(
album: album,
heroTagSuffix: heroSuffix,
initialImageUrl: imageUrl,
),
),
);
@@ -833,12 +1079,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? Image.network(
imageUrl,
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (_, __, ___) => Center(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.album_rounded,
size: 64,
@@ -857,7 +1105,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
Hero(
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + '_$heroSuffix',
child: Material(
@@ -940,10 +1188,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
height: 56,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? Image.network(
imageUrl,
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
color: colorScheme.onSurfaceVariant,
),
@@ -979,6 +1229,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: AlbumDetailsScreen(
album: album,
heroTagSuffix: 'artist_albums',
initialImageUrl: imageUrl,
),
),
);
+52 -29
View File
@@ -19,12 +19,14 @@ class AudiobookAuthorScreen extends StatefulWidget {
final String authorName;
final List<Audiobook> audiobooks;
final String? heroTagSuffix;
final String? initialAuthorImageUrl;
const AudiobookAuthorScreen({
super.key,
required this.authorName,
required this.audiobooks,
this.heroTagSuffix,
this.initialAuthorImageUrl,
});
@override
@@ -48,6 +50,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
void initState() {
super.initState();
_audiobooks = List.from(widget.audiobooks);
// Use initial image URL immediately for smooth hero animation
_authorImageUrl = widget.initialAuthorImageUrl;
_loadViewPreferences();
_sortAudiobooks();
_loadAuthorImage();
@@ -146,10 +150,16 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
@@ -168,35 +178,41 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
Hero(
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
child: Container(
width: 200,
height: 200,
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: ClipOval(
child: _authorImageUrl != null
? CachedNetworkImage(
imageUrl: _authorImageUrl!,
fit: BoxFit.cover,
width: 200,
height: 200,
placeholder: (_, __) => Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
child: Stack(
alignment: Alignment.center,
children: [
// Icon always present underneath
Icon(
MdiIcons.accountOutline,
size: coverSize * 0.5,
color: colorScheme.onPrimaryContainer,
),
// Image covers icon when loaded
if (_authorImageUrl != null)
SizedBox(
width: coverSize,
height: coverSize,
child: ClipOval(
child: CachedNetworkImage(
imageUrl: _authorImageUrl!,
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
memCacheWidth: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
),
)
: Icon(
MdiIcons.accountOutline,
size: 100,
color: colorScheme.onPrimaryContainer,
),
),
],
),
),
),
@@ -281,8 +297,10 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
),
_buildAudiobookSliver(),
const SliverToBoxAdapter(child: SizedBox(height: 140)),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
);
},
),
),
);
@@ -329,7 +347,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final heroSuffix = 'author${_heroTagSuffix}';
return InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -349,6 +367,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
@@ -443,6 +463,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
@@ -508,17 +530,18 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
initialImageUrl: initialImageUrl,
),
),
);
+74 -82
View File
@@ -5,21 +5,26 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class AudiobookDetailScreen extends StatefulWidget {
final Audiobook audiobook;
final String? heroTagSuffix;
/// Initial image URL from the source for seamless hero animation
final String? initialImageUrl;
const AudiobookDetailScreen({
super.key,
required this.audiobook,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
@@ -119,10 +124,10 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
final newState = !_isFavorite;
bool success;
if (newState) {
String actualProvider = widget.audiobook.provider;
@@ -136,12 +141,17 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
orElse: () => widget.audiobook.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
_logger.log('Adding audiobook to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('audiobook', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
@@ -161,23 +171,30 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
throw Exception('Could not determine library ID for this audiobook');
}
_logger.log('Removing audiobook from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('audiobook', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
}
setState(() {
_isFavorite = newState;
});
if (success) {
setState(() {
_isFavorite = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
duration: const Duration(seconds: 1),
),
);
);
}
}
} catch (e) {
_logger.log('Error toggling audiobook favorite: $e');
@@ -194,62 +211,22 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.hidePlayer();
final screenHeight = MediaQuery.of(context).size.height;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final sheetHeight = (screenHeight * 0.45) + bottomPadding;
showModalBottomSheet(
showPlayerPickerSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => Container(
height: sheetHeight,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
const SizedBox(height: 16),
Text(
S.of(context)!.playOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: players.isEmpty
? Center(child: Text(S.of(context)!.noPlayersAvailable))
: ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16),
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);
maProvider.selectPlayer(player);
maProvider.setCurrentAudiobook(_audiobook);
await maProvider.api?.playAudiobook(
player.playerId,
widget.audiobook,
);
},
);
},
),
),
],
),
),
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.setCurrentAudiobook(_audiobook);
await maProvider.api?.playAudiobook(
player.playerId,
widget.audiobook,
);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
@@ -414,7 +391,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
final providerImageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
// Use initialImageUrl as fallback for seamless hero animation
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
@@ -449,10 +428,16 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 350,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
@@ -521,24 +506,29 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 280,
height: 280,
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 120,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 120,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
@@ -546,7 +536,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
: Center(
child: Icon(
MdiIcons.bookOutline,
size: 120,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
@@ -577,7 +567,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
),
),
const SizedBox(height: 8),
Spacing.vGap8,
// Author
Text(
@@ -599,7 +589,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
],
// Duration & Progress
const SizedBox(height: 16),
Spacing.vGap16,
Row(
children: [
if (book.duration != null) ...[
@@ -723,7 +713,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
@@ -747,7 +737,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Spacing.vGap8,
InkWell(
onTap: () {
setState(() {
@@ -778,7 +768,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Spacing.vGap8,
],
),
),
@@ -788,8 +778,10 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Use MA chapters if available, otherwise use ABS chapters
..._buildChapterSection(book, colorScheme, textTheme),
const SliverToBoxAdapter(child: SizedBox(height: 140)),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
);
},
),
),
);
+24 -9
View File
@@ -275,11 +275,17 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
return Scaffold(
backgroundColor: colorScheme.surface,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
// App bar with series cover collage (matches audiobook detail screen)
SliverAppBar(
expandedHeight: 350,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
@@ -292,12 +298,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
// Series cover collage - same size as audiobook detail cover
// Series cover collage - responsive size
Hero(
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
child: Container(
width: 280,
height: 280,
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
@@ -445,10 +451,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
_buildAudiobookSliver(maProvider),
// Bottom padding for mini player
const SliverToBoxAdapter(
SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
);
},
),
);
}
@@ -517,6 +525,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
@@ -633,7 +643,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
final heroSuffix = 'series_${widget.series.id}';
return InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -653,6 +663,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
@@ -746,6 +758,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
@@ -811,17 +825,18 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
initialImageUrl: initialImageUrl,
),
),
);
+12
View File
@@ -62,6 +62,18 @@ class _HomeScreenState extends State<HomeScreen> {
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
// If player reveal (device list) is visible, dismiss it first (with animation)
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
GlobalPlayerOverlay.dismissPlayerReveal();
return;
}
// If queue panel is open, close it first (before collapsing player)
if (GlobalPlayerOverlay.isQueuePanelOpen) {
GlobalPlayerOverlay.closeQueuePanel();
return;
}
// If global player is expanded, collapse it first
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
+4 -2
View File
@@ -47,7 +47,9 @@ class LibraryAlbumsScreen extends StatelessWidget {
Widget _buildAlbumsList(BuildContext context, List<Album> albums, bool isLoading) {
final colorScheme = Theme.of(context).colorScheme;
if (isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (albums.isEmpty && isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
@@ -62,7 +64,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: () async {
await context.read<MusicAssistantProvider>().loadLibrary();
},
+4 -2
View File
@@ -49,7 +49,9 @@ class LibraryArtistsScreen extends StatelessWidget {
Widget _buildArtistsList(BuildContext context, List<Artist> artists, bool isLoading) {
final colorScheme = Theme.of(context).colorScheme;
if (isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (artists.isEmpty && isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
@@ -64,7 +66,7 @@ class LibraryArtistsScreen extends StatelessWidget {
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: () async {
await context.read<MusicAssistantProvider>().loadLibrary();
},
+61 -39
View File
@@ -4,8 +4,12 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../widgets/common/empty_state.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import 'playlist_details_screen.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
class LibraryPlaylistsScreen extends StatefulWidget {
const LibraryPlaylistsScreen({super.key});
@@ -30,17 +34,11 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
});
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final playlists = await maProvider.api!.getPlaylists(limit: 100);
setState(() {
_playlists = playlists;
_isLoading = false;
});
} else {
setState(() {
_isLoading = false;
});
}
final playlists = await maProvider.getPlaylists(limit: 100);
setState(() {
_playlists = playlists;
_isLoading = false;
});
}
@override
@@ -75,7 +73,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (_isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (_playlists.isEmpty && _isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
@@ -87,7 +87,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: _loadPlaylists,
child: ListView.builder(
itemCount: _playlists.length,
@@ -108,34 +108,55 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Unique suffix for this context to avoid hero tag conflicts
const heroSuffix = '_playlists';
return RepaintBoundary(
child: ListTile(
key: ValueKey(playlist.itemId),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
leading: Hero(
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
),
),
child: imageUrl == null
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
playlist.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
title: Hero(
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: Material(
color: Colors.transparent,
child: Text(
playlist.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
playlist.trackCount != null
@@ -148,16 +169,17 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
overflow: TextOverflow.ellipsis,
),
trailing: playlist.favorite == true
? const Icon(Icons.favorite, color: Colors.red, size: 20)
? const Icon(Icons.favorite, color: StatusColors.favorite, size: 20)
: null,
onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistDetailsScreen(
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
provider: playlist.provider,
itemId: playlist.itemId,
heroTagSuffix: 'playlists',
initialImageUrl: imageUrl,
),
),
);
+3 -1
View File
@@ -42,7 +42,9 @@ class LibraryTracksScreen extends StatelessWidget {
BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (provider.tracks.isEmpty && provider.isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
+12
View File
@@ -443,6 +443,18 @@ class _LoginScreenState extends State<LoginScreen> {
}
if (provider.isConnected) {
// For first-time users, wait for player selection so the welcome
// overlay can appear immediately without home screen flash.
// This matches the logic in AppStartup._checkAndConnect().
final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!hasCompletedOnboarding) {
// First-time user: wait for connection + player selection
for (int i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250));
if (provider.selectedPlayer != null) break;
}
}
// Navigate to home screen
if (mounted) {
// Ensure keyboard is closed before navigating
+161 -7
View File
@@ -13,6 +13,9 @@ import '../widgets/artist_row.dart';
import '../widgets/track_row.dart';
import '../widgets/audiobook_row.dart';
import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart';
import '../widgets/podcast_row.dart';
import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -35,6 +38,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
bool _showFavoritePodcasts = false;
// Audiobook rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
@@ -45,19 +51,89 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
@override
bool get wantKeepAlive => true;
// Track if we had empty favorites on first load (to know when to refresh)
bool _hadEmptyFavoritesOnLoad = false;
SyncStatus? _lastSyncStatus;
int _lastArtistCount = 0;
MusicAssistantProvider? _providerListeningTo;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadSettings();
// Listen to sync completion to refresh favorite rows when data becomes available
SyncService.instance.addListener(_onSyncChanged);
_lastSyncStatus = SyncService.instance.status;
_checkInitialFavoriteState();
}
@override
void dispose() {
SyncService.instance.removeListener(_onSyncChanged);
// Clean up provider listener if attached
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
/// Check if favorites are empty on initial load (so we know to refresh after sync)
void _checkInitialFavoriteState() {
// Schedule after first frame to ensure provider is accessible
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
_lastArtistCount = provider.artists.length;
// Check if artists list is empty - favorites will also be empty
if (_lastArtistCount == 0) {
_hadEmptyFavoritesOnLoad = true;
_logger.log('📋 Home: favorites empty on load, will refresh after sync');
// Also listen to provider for when cache loads (before sync completes)
_providerListeningTo = provider;
provider.addListener(_onProviderChanged);
}
});
}
/// Called when MusicAssistantProvider changes (for cache load detection)
void _onProviderChanged() {
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
final currentCount = _providerListeningTo!.artists.length;
// If artists went from 0 to non-zero, refresh
if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false;
_providerListeningTo!.removeListener(_onProviderChanged);
_providerListeningTo = null;
setState(() {
_refreshKey = UniqueKey();
});
}
_lastArtistCount = currentCount;
}
/// Called when SyncService status changes
void _onSyncChanged() {
final newStatus = SyncService.instance.status;
// When sync completes and we had empty favorites, refresh to show new data
if (_lastSyncStatus == SyncStatus.syncing &&
newStatus == SyncStatus.completed &&
_hadEmptyFavoritesOnLoad) {
_logger.log('🔄 Home: sync completed, refreshing favorite rows');
_hadEmptyFavoritesOnLoad = false;
// Clean up provider listener if still attached
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
if (mounted) {
setState(() {
_refreshKey = UniqueKey();
});
}
}
_lastSyncStatus = newStatus;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Reload settings when app resumes (coming back from settings)
@@ -73,6 +149,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
@@ -85,6 +164,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
@@ -194,7 +276,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
RefreshIndicator(
onRefresh: _onRefresh,
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
child: _buildConnectedView(context, maProvider),
),
// Connecting banner overlay (doesn't affect layout)
@@ -238,15 +320,50 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
}
/// Count how many rows are currently enabled
int _countEnabledRows() {
int count = 0;
for (final rowId in _homeRowOrder) {
if (_isRowEnabled(rowId)) count++;
}
return count;
}
/// Check if a specific row is enabled
bool _isRowEnabled(String rowId) {
switch (rowId) {
case 'recent-albums': return _showRecentAlbums;
case 'discover-artists': return _showDiscoverArtists;
case 'discover-albums': return _showDiscoverAlbums;
case 'continue-listening': return _showContinueListeningAudiobooks;
case 'discover-audiobooks': return _showDiscoverAudiobooks;
case 'discover-series': return _showDiscoverSeries;
case 'favorite-albums': return _showFavoriteAlbums;
case 'favorite-artists': return _showFavoriteArtists;
case 'favorite-tracks': return _showFavoriteTracks;
case 'favorite-playlists': return _showFavoritePlaylists;
case 'favorite-radio-stations': return _showFavoriteRadioStations;
case 'favorite-podcasts': return _showFavoritePodcasts;
default: return false;
}
}
Widget _buildConnectedView(
BuildContext context, MusicAssistantProvider provider) {
// Use LayoutBuilder to get available screen height
return LayoutBuilder(
builder: (context, constraints) {
// Simple calculation: available height divided by 3 rows
// Each row includes its title, artwork, and info
// Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
final rowHeight = availableHeight / 3;
// Account for margins between rows (2px each)
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
// For 4+ rows, use original calculation so 4th row stays hidden (scrollable)
const marginSize = 2.0;
final enabledRows = _countEnabledRows();
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
final rowHeight = (availableHeight - marginsInView) / 3;
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
@@ -288,6 +405,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) {
// Add spacing between rows (not before first row)
if (rows.isNotEmpty) {
rows.add(const SizedBox(height: 2.0));
}
rows.add(widget);
}
}
@@ -304,6 +425,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('recent-albums'),
title: S.of(context)!.recentlyPlayed,
loadAlbums: () => provider.getRecentAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedRecentAlbums(),
rowHeight: rowHeight,
);
case 'discover-artists':
@@ -312,6 +434,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('discover-artists'),
title: S.of(context)!.discoverArtists,
loadArtists: () => provider.getDiscoverArtistsWithCache(),
getCachedArtists: () => provider.getCachedDiscoverArtists(),
rowHeight: rowHeight,
);
case 'discover-albums':
@@ -320,6 +443,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('discover-albums'),
title: S.of(context)!.discoverAlbums,
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
rowHeight: rowHeight,
);
case 'continue-listening':
@@ -327,7 +451,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
return AudiobookRow(
key: const ValueKey('continue-listening'),
title: S.of(context)!.continueListening,
loadAudiobooks: () => provider.getInProgressAudiobooks(),
loadAudiobooks: () => provider.getInProgressAudiobooksWithCache(),
getCachedAudiobooks: () => provider.getCachedInProgressAudiobooks(),
rowHeight: rowHeight,
);
case 'discover-audiobooks':
@@ -335,7 +460,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
return AudiobookRow(
key: const ValueKey('discover-audiobooks'),
title: S.of(context)!.discoverAudiobooks,
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
loadAudiobooks: () => provider.getDiscoverAudiobooksWithCache(),
getCachedAudiobooks: () => provider.getCachedDiscoverAudiobooks(),
rowHeight: rowHeight,
);
case 'discover-series':
@@ -343,7 +469,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
return SeriesRow(
key: const ValueKey('discover-series'),
title: S.of(context)!.discoverSeries,
loadSeries: () => provider.getDiscoverSeries(),
loadSeries: () => provider.getDiscoverSeriesWithCache(),
getCachedSeries: () => provider.getCachedDiscoverSeries(),
rowHeight: rowHeight,
);
case 'favorite-albums':
@@ -370,6 +497,33 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
loadTracks: () => provider.getFavoriteTracks(),
rowHeight: rowHeight,
);
case 'favorite-playlists':
if (!_showFavoritePlaylists) return null;
return PlaylistRow(
key: const ValueKey('favorite-playlists'),
title: S.of(context)!.favoritePlaylists,
loadPlaylists: () => provider.getFavoritePlaylists(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
case 'favorite-radio-stations':
if (!_showFavoriteRadioStations) return null;
return RadioStationRow(
key: const ValueKey('favorite-radio-stations'),
title: S.of(context)!.favoriteRadioStations,
loadRadioStations: () => provider.getFavoriteRadioStations(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
case 'favorite-podcasts':
if (!_showFavoritePodcasts) return null;
return PodcastRow(
key: const ValueKey('favorite-podcasts'),
title: S.of(context)!.favoritePodcasts,
loadPodcasts: () => provider.getFavoritePodcasts(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
default:
return null;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+873
View File
@@ -0,0 +1,873 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
class PodcastDetailScreen extends StatefulWidget {
final MediaItem podcast;
final String? heroTagSuffix;
final String? initialImageUrl;
const PodcastDetailScreen({
super.key,
required this.podcast,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
State<PodcastDetailScreen> createState() => _PodcastDetailScreenState();
}
class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final _logger = DebugLogger();
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
List<MediaItem> _episodes = [];
bool _isLoadingEpisodes = false;
bool _isDescriptionExpanded = false;
bool _isInLibrary = false;
String? _expandedEpisodeId; // Track which episode is expanded for actions
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
void initState() {
super.initState();
_isInLibrary = _checkIfInLibrary(widget.podcast);
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
}
});
_loadEpisodes();
});
}
/// Check if podcast is in library
bool _checkIfInLibrary(MediaItem podcast) {
if (podcast.provider == 'library') return true;
return podcast.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
String? actualItemId;
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
// For adding to library, we MUST use a non-library provider
final nonLibraryMapping = widget.podcast.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
// Fallback to item's own provider if no non-library mapping found
if (actualProvider == null || actualItemId == null) {
if (widget.podcast.provider != 'library') {
actualProvider = widget.podcast.provider;
actualItemId = widget.podcast.itemId;
} else {
// Item is library-only, can't add
_logger.log('Cannot add to library: podcast is library-only');
return;
}
}
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
mediaType: 'podcast',
provider: actualProvider,
itemId: actualItemId,
);
} else {
// Remove from library
int? libraryItemId;
if (widget.podcast.provider == 'library') {
libraryItemId = int.tryParse(widget.podcast.itemId);
} else if (widget.podcast.providerMappings != null) {
final libraryMapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.podcast.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Cannot remove from library: no library ID found');
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling podcast library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update library: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
// Use initialImageUrl first for consistency with hero animation
final imageUrl = widget.initialImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
if (imageUrl != null) {
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
if (mounted && colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
}
} catch (e) {
_logger.log('🎙️ Error extracting colors: $e');
}
}
}
Future<void> _loadEpisodes() async {
if (_isLoadingEpisodes) return;
setState(() {
_isLoadingEpisodes = true;
});
try {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
if (mounted) {
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
// Debug: Log first episode metadata to see available fields
if (episodes.isNotEmpty) {
final first = episodes.first;
_logger.log('🎙️ First episode metadata keys: ${first.metadata?.keys.toList()}');
_logger.log('🎙️ First episode metadata: ${first.metadata}');
}
setState(() {
_episodes = episodes;
});
}
}
} catch (e) {
_logger.log('🎙️ Error loading episodes: $e');
} finally {
if (mounted) {
setState(() {
_isLoadingEpisodes = false;
});
}
}
}
void _playEpisode(MediaItem episode) async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) {
try {
// Set podcast context before playing so player UI shows correct podcast name
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing: ${episode.name}'),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('🎙️ Error playing episode: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play episode: $e')),
);
}
}
} else {
_showPlayOnMenu(context, episode);
}
}
void _showPlayOnMenu(BuildContext context, MediaItem episode) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
// Set podcast context before playing so player UI shows correct podcast name
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
String _formatDuration(Duration? duration) {
if (duration == null || duration == Duration.zero) return '';
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes} min';
}
/// Format release date from episode metadata
/// Tries various common field names for publication date
String? _formatReleaseDate(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
// Try various common field names for publication date
dynamic dateValue = metadata['published'] ??
metadata['pub_date'] ??
metadata['release_date'] ??
metadata['aired'] ??
metadata['timestamp'];
if (dateValue == null) return null;
try {
DateTime? date;
if (dateValue is String) {
date = DateTime.tryParse(dateValue);
} else if (dateValue is int) {
// Unix timestamp
date = DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
}
if (date != null) {
// Format as "Jan 15, 2024"
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
} catch (e) {
_logger.log('🎙️ Error parsing release date: $e');
}
return null;
}
/// Add episode to queue on current player
Future<void> _addToQueue(MediaItem episode) async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
// Convert MediaItem to Track for queue addition
final track = Track(
itemId: episode.itemId,
provider: episode.provider,
name: episode.name,
uri: episode.uri,
duration: episode.duration,
metadata: episode.metadata,
);
await maProvider.addTrackToQueue(selectedPlayer.playerId, track);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('🎙️ Error adding episode to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))),
);
}
}
}
@override
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation
// watch() rebuilds on ANY provider change (library updates, etc.) causing animation jank
// select() only rebuilds when the specific podcast image URL changes
final providerImageUrl = context.select<MusicAssistantProvider, String?>(
(provider) => provider.getPodcastImageUrl(widget.podcast),
);
// Use initialImageUrl for seamless hero animation (same URL as source)
final imageUrl = widget.initialImageUrl ?? providerImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveLightScheme,
);
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveDarkScheme,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
ColorScheme? adaptiveScheme;
if (adaptiveTheme) {
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
}
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final description = widget.podcast.metadata?['description'] as String?;
final author = widget.podcast.metadata?['author'] as String?;
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
clearAdaptiveColorsOnBack(context);
}
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () {
clearAdaptiveColorsOnBack(context);
Navigator.pop(context);
},
color: colorScheme.onSurface,
),
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
Hero(
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + _heroTagSuffix,
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Podcast title
Text(
widget.podcast.name,
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
if (author != null) ...[
const SizedBox(height: 4),
Text(
author,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 16),
// Action buttons row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
// Main Play Latest Episode Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _isLoadingEpisodes || _episodes.isEmpty
? null
: () => _playEpisode(_episodes.first),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor: colorScheme.primary.withOpacity(0.38),
disabledForegroundColor: colorScheme.onPrimary.withOpacity(0.38),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
const SizedBox(width: 12),
// "Play on..." Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoadingEpisodes || _episodes.isEmpty
? null
: () => _showPlayOnMenu(context, _episodes.first),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// Library button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: 16),
// Description
if (description != null && description.isNotEmpty) ...[
GestureDetector(
onTap: () {
setState(() {
_isDescriptionExpanded = !_isDescriptionExpanded;
});
},
child: AnimatedCrossFade(
firstChild: Text(
description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
secondChild: Text(
description,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
crossFadeState: _isDescriptionExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
),
const SizedBox(height: 16),
],
// Episodes header
Row(
children: [
Text(
S.of(context)!.episodes,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(width: 8),
if (_episodes.isNotEmpty)
Text(
'(${_episodes.length})',
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
],
),
),
),
// Episodes list
if (_isLoadingEpisodes)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: CircularProgressIndicator(color: colorScheme.primary),
),
),
)
else if (_episodes.isEmpty)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
MdiIcons.microphoneOff,
size: 48,
color: colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
'No episodes found',
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final episode = _episodes[index];
final episodeDescription = episode.metadata?['description'] as String?;
final duration = episode.duration;
final episodeId = episode.uri ?? episode.itemId;
final isExpanded = _expandedEpisodeId == episodeId;
final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
// Try to get release date from metadata
final releaseDate = _formatReleaseDate(episode.metadata);
return Column(
children: [
InkWell(
onTap: () => _playEpisode(episode),
onLongPress: () {
setState(() {
_expandedEpisodeId = isExpanded ? null : episodeId;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode cover (bigger than before)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 72,
height: 72,
color: colorScheme.surfaceContainerHighest,
child: episodeImageUrl != null
? CachedNetworkImage(
imageUrl: episodeImageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.name,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
episodeDescription,
maxLines: isExpanded ? 100 : 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
const SizedBox(height: 6),
// Duration and release date row
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 14,
color: colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 4),
Text(
_formatDuration(duration),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
if (duration != null && duration > Duration.zero && releaseDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (releaseDate != null)
Text(
releaseDate,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
],
),
),
],
),
),
),
// Expanded action buttons (matching album track style)
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
child: Row(
children: [
// Play button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _playEpisode(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.play_arrow, size: 20),
),
),
const SizedBox(width: 10),
// Play on... button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context, episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addToQueue(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
],
),
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
);
},
childCount: _episodes.length,
),
),
// Bottom padding for mini player
SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
);
},
),
),
);
}
}
+68
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/player_picker_sheet.dart';
import '../l10n/app_localizations.dart';
class QueueScreen extends StatefulWidget {
@@ -67,6 +68,68 @@ class _QueueScreenState extends State<QueueScreen> {
}
}
Future<void> _handleTransferQueue(MusicAssistantProvider maProvider) async {
final player = maProvider.selectedPlayer;
if (player == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
// Get available players, excluding the current source player
final allPlayers = maProvider.availablePlayers;
final targetPlayers = allPlayers
.where((p) => p.playerId != player.playerId && p.available)
.toList();
if (targetPlayers.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No other players available to transfer to'),
duration: Duration(seconds: 2),
),
);
return;
}
// Show player picker sheet
await showPlayerPickerSheet(
context: context,
title: 'Transfer Queue To',
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('Queue transferred to ${targetPlayer.name}'),
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
_logger.log('Error transferring queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to transfer queue: $e'),
backgroundColor: Colors.red.shade700,
duration: const Duration(seconds: 3),
),
);
}
}
},
);
}
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
@@ -87,6 +150,11 @@ class _QueueScreenState extends State<QueueScreen> {
),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.swap_horiz_rounded),
tooltip: 'Transfer queue',
onPressed: () => _handleTransferQueue(maProvider),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadQueue,
File diff suppressed because it is too large Load Diff
+445 -57
View File
@@ -6,6 +6,7 @@ import '../providers/music_assistant_provider.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import '../theme/app_theme.dart';
import '../widgets/global_player_overlay.dart';
import 'debug_log_screen.dart';
import 'login_screen.dart';
@@ -28,6 +29,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
bool _showFavoritePodcasts = false;
// Audiobook home rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
@@ -37,6 +41,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Audiobook libraries
List<Map<String, String>> _discoveredLibraries = [];
Map<String, bool> _libraryEnabled = {};
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
bool _volumePrecisionMode = true;
// Hint settings
bool _showHints = true;
// Library settings
bool _showOnlyArtistsWithAlbums = false;
// Music provider filter settings
List<String> _disabledProviders = [];
@override
void initState() {
@@ -62,6 +76,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
// Load audiobook home row settings
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
@@ -81,6 +98,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
libraryEnabled[path] = enabled == null || enabled.contains(path);
}
// Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings
final showHints = await SettingsService.getShowHints();
// Load library settings
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
// Load music provider filter settings
final disabledProviders = await SettingsService.getDisabledMusicProviders();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -89,12 +120,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContinueAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveredLibraries = discovered;
_libraryEnabled = libraryEnabled;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
_disabledProviders = disabledProviders;
});
}
}
@@ -128,6 +168,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
case 'favorite-tracks':
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
case 'favorite-playlists':
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
case 'favorite-radio-stations':
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
case 'favorite-podcasts':
return {'title': s.favoritePodcasts, 'subtitle': s.showFavoritePodcasts};
default:
return {'title': rowId, 'subtitle': ''};
}
@@ -154,6 +200,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
return _showFavoriteArtists;
case 'favorite-tracks':
return _showFavoriteTracks;
case 'favorite-playlists':
return _showFavoritePlaylists;
case 'favorite-radio-stations':
return _showFavoriteRadioStations;
case 'favorite-podcasts':
return _showFavoritePodcasts;
default:
return false;
}
@@ -199,6 +251,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteTracks = value;
SettingsService.setShowFavoriteTracks(value);
break;
case 'favorite-playlists':
_showFavoritePlaylists = value;
SettingsService.setShowFavoritePlaylists(value);
break;
case 'favorite-radio-stations':
_showFavoriteRadioStations = value;
SettingsService.setShowFavoriteRadioStations(value);
break;
case 'favorite-podcasts':
_showFavoritePodcasts = value;
SettingsService.setShowFavoritePodcasts(value);
break;
}
});
}
@@ -265,13 +329,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Logo - same size as login screen (50% of screen width)
// Use color filter to make logo dark in light theme
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 48.0),
child: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
child: Theme.of(context).brightness == Brightness.light
? ColorFiltered(
colorFilter: const ColorFilter.mode(
Color(0xFF1a1a1a), // Dark color for light theme
BlendMode.srcIn,
),
child: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
)
: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
),
// Connection status box - centered with border radius like theme boxes
@@ -410,7 +487,137 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 16),
// Language picker
// Accent color picker
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
final isDisabled = themeProvider.useMaterialTheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.accentColor,
style: TextStyle(
color: isDisabled
? colorScheme.onSurface.withOpacity(0.4)
: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: accentColorOptions.map((color) {
final isSelected = themeProvider.customColor.value == color.value;
return GestureDetector(
onTap: isDisabled ? null : () {
themeProvider.setCustomColor(color);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: 40,
height: 40,
decoration: BoxDecoration(
color: isDisabled ? color.withOpacity(0.3) : color,
shape: BoxShape.circle,
border: isSelected && !isDisabled
? Border.all(color: colorScheme.onSurface, width: 3)
: null,
boxShadow: isSelected && !isDisabled
? [BoxShadow(color: color.withOpacity(0.5), blurRadius: 8, spreadRadius: 2)]
: null,
),
child: isSelected && !isDisabled
? Icon(Icons.check, color: Colors.white, size: 20)
: null,
),
);
}).toList(),
),
],
),
);
},
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.materialYou,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.materialYouDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.useMaterialTheme,
onChanged: (value) {
themeProvider.setUseMaterialTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.adaptiveTheme,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.extractColorsFromArtwork,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.adaptiveTheme,
onChanged: (value) {
themeProvider.setAdaptiveTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
),
const SizedBox(height: 32),
// Language section
Text(
S.of(context)!.language,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Consumer<LocaleProvider>(
builder: (context, localeProvider, _) {
String getLanguageName(String? code) {
@@ -483,6 +690,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('Français'),
value: 'fr',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('fr'));
Navigator.pop(dialogContext);
},
),
],
),
);
@@ -493,64 +709,236 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
const SizedBox(height: 32),
// Music Providers section
if (provider.musicProviders.isNotEmpty) ...[
Text(
'Music Providers',
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Filter content shown in Home, Library, and Search',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: provider.musicProviders.map((musicProvider) {
final isEnabled = !_disabledProviders.contains(musicProvider.instanceId);
return SwitchListTile(
title: Text(
musicProvider.name,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: musicProvider.isStreamingProvider
? Text(
'Streaming',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 12,
),
)
: null,
value: isEnabled,
onChanged: (value) async {
await provider.toggleProviderEnabled(musicProvider.instanceId, value);
setState(() {
if (value) {
_disabledProviders.remove(musicProvider.instanceId);
} else {
_disabledProviders.add(musicProvider.instanceId);
}
});
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
);
}).toList(),
),
),
const SizedBox(height: 32),
],
// Player section
Text(
S.of(context)!.players,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.materialYou,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.materialYouDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.useMaterialTheme,
onChanged: (value) {
themeProvider.setUseMaterialTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.preferLocalPlayer,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.preferLocalPlayerDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _preferLocalPlayer,
onChanged: (value) {
setState(() => _preferLocalPlayer = value);
SettingsService.setPreferLocalPlayer(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.smartSortPlayers,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.smartSortPlayersDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _smartSortPlayers,
onChanged: (value) {
setState(() => _smartSortPlayers = value);
SettingsService.setSmartSortPlayers(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Volume precision mode',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hold still while adjusting volume for fine control',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _volumePrecisionMode,
onChanged: (value) {
setState(() => _volumePrecisionMode = value);
SettingsService.setVolumePrecisionMode(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
// Hints & Tips section
Text(
S.of(context)!.hintsAndTips,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.showHintsDescription,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.showHints,
style: TextStyle(color: colorScheme.onSurface),
),
value: _showHints,
onChanged: (value) {
setState(() => _showHints = value);
SettingsService.setShowHints(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
// Library section
Text(
S.of(context)!.library,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Show only artists with albums',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hide artists that have no albums in your library',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 12,
),
child: SwitchListTile(
title: Text(
S.of(context)!.adaptiveTheme,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.extractColorsFromArtwork,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.adaptiveTheme,
onChanged: (value) {
themeProvider.setAdaptiveTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
),
value: _showOnlyArtistsWithAlbums,
onChanged: (value) async {
setState(() => _showOnlyArtistsWithAlbums = value);
await SettingsService.setShowOnlyArtistsWithAlbums(value);
// Force sync library to apply the new filter at API level
if (mounted) {
context.read<MusicAssistantProvider>().forceLibrarySync();
}
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
+20 -4
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
@@ -11,6 +12,12 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
final AuthManager authManager;
final _logger = DebugLogger();
// Stream subscriptions for proper cleanup
StreamSubscription? _interruptionSubscription;
StreamSubscription? _becomingNoisySubscription;
StreamSubscription? _playbackEventSubscription;
StreamSubscription? _currentIndexSubscription;
// Track current metadata separately from what's in the notification
// This allows us to update the notification when metadata arrives late
MediaItem? _currentMediaItem;
@@ -42,7 +49,7 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
await session.configure(const AudioSessionConfiguration.music());
// Handle audio interruptions
session.interruptionEventStream.listen((event) {
_interruptionSubscription = session.interruptionEventStream.listen((event) {
if (event.begin) {
switch (event.type) {
case AudioInterruptionType.duck:
@@ -68,15 +75,15 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
});
// Handle becoming noisy (headphones unplugged)
session.becomingNoisyEventStream.listen((_) {
_becomingNoisySubscription = session.becomingNoisyEventStream.listen((_) {
pause();
});
// Broadcast playback state changes
_player.playbackEventStream.listen(_broadcastState);
_playbackEventSubscription = _player.playbackEventStream.listen(_broadcastState);
// Broadcast current media item changes
_player.currentIndexStream.listen((_) {
_currentIndexSubscription = _player.currentIndexStream.listen((_) {
if (_currentMediaItem != null) {
mediaItem.add(_currentMediaItem);
}
@@ -293,4 +300,13 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
Stream<Duration?> get durationStream => _player.durationStream;
MediaItem? get currentMediaItem => _currentMediaItem;
/// Dispose of resources and cancel all subscriptions
Future<void> dispose() async {
await _interruptionSubscription?.cancel();
await _becomingNoisySubscription?.cancel();
await _playbackEventSubscription?.cancel();
await _currentIndexSubscription?.cancel();
await _player.dispose();
}
}
+98 -19
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import 'no_auth_strategy.dart';
@@ -86,6 +87,23 @@ class AuthManager {
return baseUrl;
}
/// Check if a hostname is a local/private IP address
bool _isLocalIpAddress(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Try to detect auth strategy for a given URL
/// Returns the strategy if successful, null if connection failed
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
@@ -187,15 +205,50 @@ class AuthManager {
_logger.log('Checking MA API at: $apiUrl');
// Try to get server info - this tells us if auth is required
// Note: The command is just 'info', not 'server/info'
final response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
// Use IOClient to control redirect behavior for local IPs
final isLocalIp = _isLocalIpAddress(uri.host);
http.Response response;
if (isLocalIp) {
// For local IPs, don't follow redirects - handle them explicitly
final client = HttpClient();
try {
final request = await client.postUrl(apiUrl);
request.followRedirects = false;
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'command': 'info'}));
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
// Check for redirect to HTTPS
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
final location = ioResponse.headers.value('location');
_logger.log('Local IP redirects to: $location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
// Don't follow the redirect - it will likely fail with cert error
// Instead, return null to trigger fallback logic
client.close();
return null;
}
}
final body = await ioResponse.transform(utf8.decoder).join();
response = http.Response(body, ioResponse.statusCode);
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.post which follows redirects
response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
}
_logger.log('MA API response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}');
@@ -255,21 +308,47 @@ class AuthManager {
/// Test if server is accessible without authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
try {
// Test root endpoint - Music Assistant doesn't have /api/info
final testUrl = serverUrl;
_logger.log('No-auth test URL: $testUrl');
final uri = Uri.parse(serverUrl);
_logger.log('No-auth test URL: $serverUrl');
final response = await http.get(
Uri.parse(testUrl),
// Don't follow redirects - we want to see if server requires auth
).timeout(const Duration(seconds: 10));
final isLocalIp = _isLocalIpAddress(uri.host);
int statusCode;
_logger.log('No-auth status: ${response.statusCode}');
if (isLocalIp) {
// For local IPs, don't follow redirects to avoid HTTPS cert issues
final client = HttpClient();
try {
final request = await client.getUrl(uri);
request.followRedirects = false;
final response = await request.close().timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
// Check for redirect to HTTPS
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
final location = response.headers.value('location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
client.close();
return false; // Can't connect - would need HTTPS which has cert issues
}
}
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.get
final response = await http.get(uri).timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
}
_logger.log('No-auth status: $statusCode');
// 200 = no auth required
// 302/307 = redirect (probably to auth)
// 302/307/308 = redirect (probably to auth or HTTPS)
// 401 = auth required
if (response.statusCode == 200) {
if (statusCode == 200) {
return true;
}
+258 -2
View File
@@ -1,20 +1,34 @@
import 'dart:convert';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../services/database_service.dart';
import '../constants/timings.dart';
/// Centralized cache service for all app data caching
/// This is a non-notifying service - it just stores and retrieves cached data
class CacheService {
final DebugLogger _logger = DebugLogger();
bool _homeRowsLoaded = false;
// Cache size limits to prevent unbounded memory growth
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
static const int _maxSearchCacheSize = 20; // Max search queries cached
static const int _maxPlayerTrackCacheSize = 30; // Max player track associations
// Home screen row caching
List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists;
List<Album>? _cachedDiscoverAlbums;
List<Audiobook>? _cachedInProgressAudiobooks;
List<Audiobook>? _cachedDiscoverAudiobooks;
List<AudiobookSeries>? _cachedDiscoverSeries;
DateTime? _recentAlbumsLastFetched;
DateTime? _discoverArtistsLastFetched;
DateTime? _discoverAlbumsLastFetched;
DateTime? _inProgressAudiobooksLastFetched;
DateTime? _discoverAudiobooksLastFetched;
DateTime? _discoverSeriesLastFetched;
// Detail screen caching
final Map<String, List<Track>> _albumTracksCache = {};
@@ -35,6 +49,7 @@ class CacheService {
// Player track cache (for smooth swipe transitions)
final Map<String, Track?> _playerTrackCache = {};
final Map<String, DateTime> _playerTrackCacheTime = {};
// ============================================================================
// HOME SCREEN ROW CACHING
@@ -57,6 +72,8 @@ class CacheService {
_cachedRecentAlbums = albums;
_recentAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} recent albums');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('recent_albums', albums.map((a) => a.toJson()).toList());
}
/// Check if discover artists cache is valid
@@ -76,6 +93,8 @@ class CacheService {
_cachedDiscoverArtists = artists;
_discoverArtistsLastFetched = DateTime.now();
_logger.log('✅ Cached ${artists.length} discover artists');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('discover_artists', artists.map((a) => a.toJson()).toList());
}
/// Check if discover albums cache is valid
@@ -95,6 +114,76 @@ class CacheService {
_cachedDiscoverAlbums = albums;
_discoverAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} discover albums');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
}
/// Check if in-progress audiobooks cache is valid
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedInProgressAudiobooks != null &&
_inProgressAudiobooksLastFetched != null &&
now.difference(_inProgressAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached in-progress audiobooks
List<Audiobook>? getCachedInProgressAudiobooks() => _cachedInProgressAudiobooks;
/// Set cached in-progress audiobooks
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
_cachedInProgressAudiobooks = audiobooks;
_inProgressAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
}
/// Check if discover audiobooks cache is valid
bool isDiscoverAudiobooksCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoverAudiobooks != null &&
_discoverAudiobooksLastFetched != null &&
now.difference(_discoverAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discover audiobooks
List<Audiobook>? getCachedDiscoverAudiobooks() => _cachedDiscoverAudiobooks;
/// Set cached discover audiobooks
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
_cachedDiscoverAudiobooks = audiobooks;
_discoverAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
}
/// Check if discover series cache is valid
bool isDiscoverSeriesCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoverSeries != null &&
_discoverSeriesLastFetched != null &&
now.difference(_discoverSeriesLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discover series
List<AudiobookSeries>? getCachedDiscoverSeries() => _cachedDiscoverSeries;
/// Set cached discover series
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
_cachedDiscoverSeries = series;
_discoverSeriesLastFetched = DateTime.now();
_logger.log('✅ Cached ${series.length} discover series');
}
/// Invalidate audiobook caches
void invalidateAudiobookCaches() {
_cachedInProgressAudiobooks = null;
_inProgressAudiobooksLastFetched = null;
_cachedDiscoverAudiobooks = null;
_discoverAudiobooksLastFetched = null;
_cachedDiscoverSeries = null;
_discoverSeriesLastFetched = null;
_logger.log('🗑️ Audiobook caches invalidated');
}
/// Invalidate home screen cache (call on pull-to-refresh)
@@ -102,9 +191,28 @@ class CacheService {
_recentAlbumsLastFetched = null;
_discoverArtistsLastFetched = null;
_discoverAlbumsLastFetched = null;
_inProgressAudiobooksLastFetched = null;
_discoverAudiobooksLastFetched = null;
_discoverSeriesLastFetched = null;
_logger.log('🗑️ Home screen cache invalidated');
}
/// Invalidate home album caches (call when albums are added/removed from library)
void invalidateHomeAlbumCaches() {
_cachedRecentAlbums = null;
_recentAlbumsLastFetched = null;
_cachedDiscoverAlbums = null;
_discoverAlbumsLastFetched = null;
_logger.log('🗑️ Home album caches invalidated');
}
/// Invalidate home artist caches (call when artists are added/removed from library)
void invalidateHomeArtistCaches() {
_cachedDiscoverArtists = null;
_discoverArtistsLastFetched = null;
_logger.log('🗑️ Home artist caches invalidated');
}
// ============================================================================
// DETAIL SCREEN CACHING
// ============================================================================
@@ -126,15 +234,23 @@ class CacheService {
void setCachedAlbumTracks(String cacheKey, List<Track> tracks) {
_albumTracksCache[cacheKey] = tracks;
_albumTracksCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_albumTracksCache, _albumTracksCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
}
/// Invalidate album tracks cache
/// Invalidate album tracks cache for a specific album
void invalidateAlbumTracksCache(String albumId) {
_albumTracksCache.remove(albumId);
_albumTracksCacheTime.remove(albumId);
}
/// Invalidate all album tracks caches (call when tracks are added/removed from library)
void invalidateAllAlbumTracksCaches() {
_albumTracksCache.clear();
_albumTracksCacheTime.clear();
_logger.log('🗑️ All album tracks caches invalidated');
}
/// Check if playlist tracks cache is valid
bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
@@ -152,15 +268,23 @@ class CacheService {
void setCachedPlaylistTracks(String cacheKey, List<Track> tracks) {
_playlistTracksCache[cacheKey] = tracks;
_playlistTracksCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_playlistTracksCache, _playlistTracksCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
}
/// Invalidate playlist tracks cache
/// Invalidate playlist tracks cache for a specific playlist
void invalidatePlaylistTracksCache(String playlistId) {
_playlistTracksCache.remove(playlistId);
_playlistTracksCacheTime.remove(playlistId);
}
/// Invalidate all playlist tracks caches (call when tracks are added/removed from library)
void invalidateAllPlaylistTracksCaches() {
_playlistTracksCache.clear();
_playlistTracksCacheTime.clear();
_logger.log('🗑️ All playlist tracks caches invalidated');
}
/// Check if artist albums cache is valid
bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
@@ -178,9 +302,17 @@ class CacheService {
void setCachedArtistAlbums(String cacheKey, List<Album> albums) {
_artistAlbumsCache[cacheKey] = albums;
_artistAlbumsCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_artistAlbumsCache, _artistAlbumsCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
}
/// Invalidate all artist albums caches (call when albums are added/removed from library)
void invalidateArtistAlbumsCache() {
_artistAlbumsCache.clear();
_artistAlbumsCacheTime.clear();
_logger.log('🗑️ Artist albums cache invalidated');
}
// ============================================================================
// SEARCH CACHING
// ============================================================================
@@ -202,9 +334,17 @@ class CacheService {
void setCachedSearchResults(String cacheKey, Map<String, List<MediaItem>> results) {
_searchCache[cacheKey] = results;
_searchCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_searchCache, _searchCacheTime, _maxSearchCacheSize);
_logger.log('✅ Cached search results for "$cacheKey"');
}
/// Invalidate all search cache (call when library items change)
void invalidateSearchCache() {
_searchCache.clear();
_searchCacheTime.clear();
_logger.log('🗑️ Search cache invalidated');
}
// ============================================================================
// PLAYER CACHING
// ============================================================================
@@ -245,9 +385,20 @@ class CacheService {
/// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
/// Get all player IDs that have cached tracks
Iterable<String> getAllCachedPlayerIds() => _playerTrackCache.keys;
/// Set cached track for a player
void setCachedTrackForPlayer(String playerId, Track? track) {
_playerTrackCache[playerId] = track;
_playerTrackCacheTime[playerId] = DateTime.now();
_evictOldestEntries(_playerTrackCache, _playerTrackCacheTime, _maxPlayerTrackCacheSize);
}
/// Clear cached track for a player (e.g., when external source is active)
void clearCachedTrackForPlayer(String playerId) {
_playerTrackCache.remove(playerId);
_playerTrackCacheTime.remove(playerId);
}
// ============================================================================
@@ -265,6 +416,7 @@ class CacheService {
_searchCache.clear();
_searchCacheTime.clear();
_playerTrackCache.clear();
_playerTrackCacheTime.clear();
_logger.log('🗑️ All detail caches cleared');
}
@@ -276,4 +428,108 @@ class CacheService {
_cachedSelectedPlayer = null;
_playersLastFetched = null;
}
// ============================================================================
// DATABASE PERSISTENCE FOR HOME ROWS
// ============================================================================
/// Load home row data from database for instant display on startup
Future<void> loadHomeRowsFromDatabase() async {
if (_homeRowsLoaded) return;
_homeRowsLoaded = true;
try {
if (!DatabaseService.instance.isInitialized) return;
// Load recent albums
final recentData = await DatabaseService.instance.getHomeRowCache('recent_albums');
if (recentData != null) {
try {
final items = (jsonDecode(recentData.itemsJson) as List)
.map((json) => Album.fromJson(json as Map<String, dynamic>))
.toList();
_cachedRecentAlbums = items;
_recentAlbumsLastFetched = recentData.lastUpdated;
_logger.log('📦 Loaded ${items.length} recent albums from database');
} catch (e) {
_logger.log('⚠️ Failed to parse recent albums: $e');
}
}
// Load discover artists
final artistsData = await DatabaseService.instance.getHomeRowCache('discover_artists');
if (artistsData != null) {
try {
final items = (jsonDecode(artistsData.itemsJson) as List)
.map((json) => Artist.fromJson(json as Map<String, dynamic>))
.toList();
_cachedDiscoverArtists = items;
_discoverArtistsLastFetched = artistsData.lastUpdated;
_logger.log('📦 Loaded ${items.length} discover artists from database');
} catch (e) {
_logger.log('⚠️ Failed to parse discover artists: $e');
}
}
// Load discover albums
final albumsData = await DatabaseService.instance.getHomeRowCache('discover_albums');
if (albumsData != null) {
try {
final items = (jsonDecode(albumsData.itemsJson) as List)
.map((json) => Album.fromJson(json as Map<String, dynamic>))
.toList();
_cachedDiscoverAlbums = items;
_discoverAlbumsLastFetched = albumsData.lastUpdated;
_logger.log('📦 Loaded ${items.length} discover albums from database');
} catch (e) {
_logger.log('⚠️ Failed to parse discover albums: $e');
}
}
} catch (e) {
_logger.log('⚠️ Error loading home rows from database: $e');
}
}
/// Persist home row data to database (fire-and-forget)
void _persistHomeRowToDatabase(String rowType, List<Map<String, dynamic>> items) {
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
await DatabaseService.instance.saveHomeRowCache(rowType, jsonEncode(items));
_logger.log('💾 Persisted $rowType to database');
} catch (e) {
_logger.log('⚠️ Failed to persist $rowType: $e');
}
}();
}
// ============================================================================
// LRU CACHE EVICTION
// ============================================================================
/// Evict oldest entries from cache maps to enforce size limit (LRU eviction)
/// Uses the timestamp map to determine which entries are oldest
void _evictOldestEntries<K, V>(
Map<K, V> cache,
Map<K, DateTime> cacheTime,
int maxSize,
) {
if (cache.length <= maxSize) return;
// Sort keys by timestamp (oldest first)
final sortedKeys = cacheTime.keys.toList()
..sort((a, b) => (cacheTime[a] ?? DateTime.now())
.compareTo(cacheTime[b] ?? DateTime.now()));
// Remove oldest entries until we're at maxSize
final keysToRemove = sortedKeys.take(cache.length - maxSize);
for (final key in keysToRemove) {
cache.remove(key);
cacheTime.remove(key);
}
if (keysToRemove.isNotEmpty) {
_logger.log('🗑️ LRU evicted ${keysToRemove.length} cache entries');
}
}
}
+155
View File
@@ -156,4 +156,159 @@ class DatabaseService {
/// Clear all cached data (useful for logout)
Future<void> clearAllCache() => db.clearAllCache();
/// Clear cached items of a specific type (e.g., 'album', 'artist')
/// Used before re-syncing to remove stale items
Future<void> clearCacheForType(String itemType) => db.clearCache(itemType);
/// Mark a specific cached item as deleted
/// Used when removing items from library for immediate database update
Future<void> markCachedItemDeleted(String itemType, String itemId) {
return db.markItemsDeleted(itemType, [itemId]);
}
// ============================================
// Player Cache Convenience Methods
// ============================================
/// Save playback state for instant resume
Future<void> savePlaybackState({
String? playerId,
String? playerName,
String? currentTrackJson,
double positionSeconds = 0.0,
bool isPlaying = false,
}) {
return db.savePlaybackState(
playerId: playerId,
playerName: playerName,
currentTrackJson: currentTrackJson,
positionSeconds: positionSeconds,
isPlaying: isPlaying,
);
}
/// Get saved playback state
Future<PlaybackStateData?> getPlaybackState() => db.getPlaybackState();
/// Cache a player with optional track info
Future<void> cachePlayer({
required String playerId,
required String playerJson,
String? currentTrackJson,
}) {
return db.cachePlayer(
playerId: playerId,
playerJson: playerJson,
currentTrackJson: currentTrackJson,
);
}
/// Cache multiple players at once
Future<void> cachePlayers(List<Map<String, dynamic>> players) {
return db.cachePlayers(players);
}
/// Get all cached players
Future<List<CachedPlayer>> getCachedPlayers() => db.getCachedPlayers();
/// Update cached track for a player
Future<void> updateCachedPlayerTrack(String playerId, String? trackJson) {
return db.updateCachedPlayerTrack(playerId, trackJson);
}
/// Clear all cached players
Future<void> clearCachedPlayers() => db.clearCachedPlayers();
/// Save queue for a player
Future<void> saveQueue(String playerId, List<String> itemJsonList) {
return db.saveQueue(playerId, itemJsonList);
}
/// Get cached queue for a player
Future<List<CachedQueueData>> getCachedQueue(String playerId) {
return db.getCachedQueue(playerId);
}
/// Clear queue for a player
Future<void> clearCachedQueue(String playerId) => db.clearCachedQueue(playerId);
// ============================================
// Home Row Cache Convenience Methods
// ============================================
/// Save home row data (recent albums, discover artists, etc.)
Future<void> saveHomeRowCache(String rowType, String itemsJson) {
return db.saveHomeRowCache(rowType, itemsJson);
}
/// Get cached home row data
Future<HomeRowCacheData?> getHomeRowCache(String rowType) {
return db.getHomeRowCache(rowType);
}
/// Clear all home row cache
Future<void> clearHomeRowCache() => db.clearHomeRowCache();
// ============================================
// Search History Convenience Methods
// ============================================
/// Save a search query to history
Future<void> saveSearchQuery(String query) => db.saveSearchQuery(query);
/// Get recent search queries (up to 10)
Future<List<String>> getRecentSearches() => db.getRecentSearches();
/// Clear all search history
Future<void> clearSearchHistory() => db.clearSearchHistory();
// ============================================
// Cast-to-Sendspin Mapping Methods
// ============================================
static const String _sendspinMappingType = 'cast_sendspin_mapping';
/// Save a Cast UUID to Sendspin ID mapping
Future<void> saveCastToSendspinMapping(String castId, String sendspinId) {
_logger.log('💾 Persisting Cast->Sendspin mapping: $castId -> $sendspinId');
return db.cacheItem(
itemType: _sendspinMappingType,
itemId: castId,
data: jsonEncode({'sendspinId': sendspinId}),
);
}
/// Get persisted Sendspin ID for a Cast UUID
Future<String?> getSendspinIdForCast(String castId) async {
final item = await db.getCachedItem(_sendspinMappingType, castId);
if (item == null) return null;
try {
final data = jsonDecode(item.data) as Map<String, dynamic>;
return data['sendspinId'] as String?;
} catch (e) {
_logger.log('Error decoding Sendspin mapping: $e');
return null;
}
}
/// Load all persisted Cast-to-Sendspin mappings
Future<Map<String, String>> getAllCastToSendspinMappings() async {
final items = await db.getCachedItems(_sendspinMappingType);
final mappings = <String, String>{};
for (final item in items) {
try {
final data = jsonDecode(item.data) as Map<String, dynamic>;
final sendspinId = data['sendspinId'] as String?;
if (sendspinId != null) {
// The itemId is the Cast UUID
mappings[item.itemId] = sendspinId;
}
} catch (e) {
_logger.log('Error decoding Sendspin mapping: $e');
}
}
_logger.log('📦 Loaded ${mappings.length} Cast->Sendspin mappings from database');
return mappings;
}
}
+94 -23
View File
@@ -1,36 +1,99 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
/// Service to generate unique per-installation player IDs
/// Simplified to match KMP client pattern: single storage key, lazy generation
/// This prevents the critical bug where multiple devices of the same model
/// would share the same player ID and trigger playback on all devices
/// Service to generate unique per-device player IDs
/// Uses stable device identifiers (ANDROID_ID) combined with MA username
/// to create persistent player IDs that survive storage wipes and support
/// multiple devices per account
class DeviceIdService {
static const String _keyLocalPlayerId = 'local_player_id';
static final _logger = DebugLogger();
static const _uuid = Uuid();
/// Get or generate a unique player ID for this installation
/// ID is generated once and persisted across app restarts
/// Format: ensemble_<uuid>
///
/// This matches the KMP client pattern: generate on first access, store once
static Future<String> getOrCreateDevicePlayerId() async {
final prefs = await SharedPreferences.getInstance();
// Cache stable device ID to avoid repeated platform calls
static String? _cachedStableDeviceId;
// Single source of truth - no complex migration logic
final existingId = prefs.getString(_keyLocalPlayerId);
if (existingId != null && existingId.startsWith('ensemble_')) {
_logger.log('Using existing player ID: $existingId');
return existingId;
/// Get stable device identifier that persists across storage wipes
/// - Android: Uses ANDROID_ID (persists until factory reset)
/// - iOS: Uses identifierForVendor (persists until app reinstall)
static Future<String> _getStableDeviceId() async {
if (_cachedStableDeviceId != null) {
return _cachedStableDeviceId!;
}
// Generate new ID on first access
final playerId = 'ensemble_${_uuid.v4()}';
await prefs.setString(_keyLocalPlayerId, playerId);
_logger.log('Generated new player ID: $playerId');
return playerId;
final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo;
// ANDROID_ID is unique per device+user combo, persists across storage wipes
// Only resets on factory reset
_cachedStableDeviceId = android.id;
} else if (Platform.isIOS) {
final ios = await deviceInfo.iosInfo;
// identifierForVendor persists across app updates but resets on reinstall
_cachedStableDeviceId = ios.identifierForVendor ?? 'unknown_ios';
} else {
// Fallback for other platforms (web, desktop)
_cachedStableDeviceId = 'unknown_platform';
}
return _cachedStableDeviceId!;
}
/// Detect if device is a tablet based on screen size
/// Uses the standard 600dp threshold (shortest side >= 600 = tablet)
static bool get isTablet {
try {
final view = ui.PlatformDispatcher.instance.views.first;
final size = view.physicalSize / view.devicePixelRatio;
return size.shortestSide >= 600;
} catch (e) {
_logger.log('Failed to detect device type: $e');
return false; // Default to phone if detection fails
}
}
/// Get or generate a unique player ID for this device
/// Format: ensemble_{username}_{deviceId} or ensemble_{deviceId}
///
/// Key behaviors:
/// - Uses stable device ID that survives storage wipes
/// - Includes MA username to support multiple devices per account
/// - Regenerates if username changes (user logged in with different account)
static Future<String> getOrCreateDevicePlayerId() async {
final prefs = await SharedPreferences.getInstance();
final stableDeviceId = await _getStableDeviceId();
final maUsername = await SettingsService.getUsername();
// Generate expected ID based on current username and device
// Use shorter device ID suffix (first 8 chars) to keep ID reasonable length
final deviceIdSuffix = stableDeviceId.length > 8
? stableDeviceId.substring(0, 8)
: stableDeviceId;
final expectedId = maUsername != null && maUsername.isNotEmpty
? 'ensemble_${maUsername}_$deviceIdSuffix'
: 'ensemble_$deviceIdSuffix';
// Check if we already have the correct ID
final existingId = prefs.getString(_keyLocalPlayerId);
if (existingId == expectedId) {
_logger.log('Using existing player ID: $expectedId');
return expectedId;
}
// ID needs to be updated (first login, username changed, or migration)
if (existingId != null) {
_logger.log('Updating player ID from $existingId to $expectedId');
} else {
_logger.log('Generated new player ID: $expectedId');
}
await prefs.setString(_keyLocalPlayerId, expectedId);
return expectedId;
}
/// Adopt an existing player ID (used when claiming a ghost player)
@@ -45,4 +108,12 @@ class DeviceIdService {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLocalPlayerId) == null;
}
/// Force regeneration of player ID on next call
/// Useful after login/logout to ensure ID matches current user
static Future<void> invalidateCachedId() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyLocalPlayerId);
_logger.log('Invalidated cached player ID');
}
}
@@ -82,5 +82,9 @@ class HardwareVolumeService {
} catch (e) {
_logger.error('Failed to stop volume button listening', context: 'VolumeService', error: e);
}
// Close StreamControllers to prevent memory leaks
await _volumeUpController.close();
await _volumeDownController.close();
}
}
+350 -77
View File
@@ -326,120 +326,301 @@ class MetadataService {
return null;
}
/// Fetches author image URL from multiple sources
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
/// Returns the image URL if found, null otherwise
static Future<String?> getAuthorImageUrl(String authorName) async {
// Check cache first
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) {
return _authorImageCache[cacheKey];
/// Generate name variations for fuzzy matching
/// Returns a list of name variations to try, in priority order
static List<String> _generateNameVariations(String authorName) {
final variations = <String>[];
final original = authorName.trim();
// Always try original first
variations.add(original);
// Handle "Lastname, Firstname" format
if (original.contains(',')) {
final parts = original.split(',').map((p) => p.trim()).toList();
if (parts.length == 2) {
variations.add('${parts[1]} ${parts[0]}'); // "Firstname Lastname"
}
}
// Try Audnexus (specifically for audiobook authors, uses Audible data)
// Normalize and expand initials for better matching
// Handles messy formats like "J. R.R Tolkien", "J.R.R. Tolkien", etc.
String normalized = original;
// First, normalize spacing around periods in initials: "R.R" -> "R. R", "J. R" -> "J. R"
// Match single letter followed by period and optional space, then another letter
normalized = normalized.replaceAllMapped(
RegExp(r'([A-Z])\.(\s?)([A-Z])'),
(m) => '${m.group(1)}. ${m.group(3)}',
);
// Also handle single letters without periods followed by period-letter: "R.R" in "J. R.R"
// This catches the case where we have "R.R" as a unit
normalized = normalized.replaceAllMapped(
RegExp(r'(\s)([A-Z])\.([A-Z])'),
(m) => '${m.group(1)}${m.group(2)}. ${m.group(3)}.',
);
// Add periods to standalone single uppercase letters (initials without periods)
// "J. R. R Tolkien" -> "J. R. R. Tolkien"
normalized = normalized.replaceAllMapped(
RegExp(r'(\s)([A-Z])(\s)'),
(m) => '${m.group(1)}${m.group(2)}.${m.group(3)}',
);
if (normalized != original && !variations.contains(normalized)) {
variations.add(normalized);
}
// Remove common titles
final titles = ['Dr.', 'Dr', 'Mr.', 'Mr', 'Mrs.', 'Mrs', 'Ms.', 'Ms',
'Prof.', 'Prof', 'Professor', 'Sir', 'Dame', 'Lord', 'Lady'];
String withoutTitle = original;
for (final title in titles) {
if (withoutTitle.toLowerCase().startsWith(title.toLowerCase() + ' ')) {
withoutTitle = withoutTitle.substring(title.length).trim();
if (withoutTitle != original) {
variations.add(withoutTitle);
}
break;
}
}
// Remove suffixes
final suffixes = [', Jr.', ', Jr', ' Jr.', ' Jr', ', Sr.', ', Sr', ' Sr.', ' Sr',
', III', ' III', ', II', ' II', ', IV', ' IV', ', PhD', ', Ph.D.'];
String withoutSuffix = withoutTitle;
for (final suffix in suffixes) {
if (withoutSuffix.toLowerCase().endsWith(suffix.toLowerCase())) {
withoutSuffix = withoutSuffix.substring(0, withoutSuffix.length - suffix.length).trim();
if (withoutSuffix != withoutTitle && !variations.contains(withoutSuffix)) {
variations.add(withoutSuffix);
}
break;
}
}
// Split into parts for further variations (use normalized name for better splitting)
final nameToSplit = normalized != original ? normalized : withoutSuffix;
final parts = nameToSplit.split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
if (parts.length >= 2) {
// Try first + last only (skip middle names/initials)
final firstLast = '${parts.first} ${parts.last}';
if (!variations.contains(firstLast)) {
variations.add(firstLast);
}
// Check if name starts with initials (single letter or letter with period)
bool isInitial(String s) => s.length == 1 || (s.length == 2 && s.endsWith('.'));
// If first part looks like an initial, try just the last name
if (isInitial(parts.first)) {
if (!variations.contains(parts.last)) {
variations.add(parts.last);
}
}
// Handle multiple initials like "J. K. Rowling" or "J. R. R. Tolkien"
// Find where initials end and actual name begins
int initialsEnd = 0;
for (int i = 0; i < parts.length - 1; i++) {
if (isInitial(parts[i])) {
initialsEnd = i + 1;
} else {
break;
}
}
if (initialsEnd > 0 && initialsEnd < parts.length) {
// Try the non-initial part of the name
final restOfName = parts.sublist(initialsEnd).join(' ');
if (restOfName.isNotEmpty && !variations.contains(restOfName)) {
variations.add(restOfName);
}
// Also try initials without periods + last name (e.g., "JRR Tolkien", "JK Rowling")
final initialsNoPeriods = parts.sublist(0, initialsEnd)
.map((s) => s.replaceAll('.', ''))
.join('');
final compactInitials = '$initialsNoPeriods ${parts.last}';
if (!variations.contains(compactInitials)) {
variations.add(compactInitials);
}
}
}
// Remove duplicates while preserving order
return variations.toSet().toList();
}
/// Try to fetch author image from Audnexus
/// Audnexus requires a two-step process:
/// 1. Search by name to get the ASIN
/// 2. Fetch full author details by ASIN to get the image
static Future<String?> _tryAudnexus(String name) async {
try {
final audnexusUri = Uri.https(
'api.audnex.us',
'/authors',
{'name': authorName},
);
// Step 1: Search for author to get ASIN
final searchUri = Uri.https('api.audnex.us', '/authors', {'name': name});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
final audnexusResponse = await http.get(audnexusUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
if (searchData is List && searchData.isNotEmpty) {
// Find the best match - prefer exact name matches
String? bestAsin;
final nameLower = name.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
if (audnexusResponse.statusCode == 200) {
final data = json.decode(audnexusResponse.body);
// Audnexus returns an array of authors
if (data is List && data.isNotEmpty) {
final author = data[0];
final imageUrl = author['image'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
_authorImageCache[cacheKey] = imageUrl;
return imageUrl;
for (final result in searchData) {
final resultName = (result['name'] as String?)?.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
final asin = result['asin'] as String?;
if (asin != null) {
if (resultName == nameLower) {
bestAsin = asin;
break; // Exact match found
}
bestAsin ??= asin; // Keep first result as fallback
}
}
if (bestAsin != null) {
// Step 2: Fetch full author details by ASIN
final detailsUri = Uri.https('api.audnex.us', '/authors/$bestAsin');
final detailsResponse = await http.get(detailsUri).timeout(const Duration(seconds: 5));
if (detailsResponse.statusCode == 200) {
final detailsData = json.decode(detailsResponse.body);
final imageUrl = detailsData['image'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
return imageUrl;
}
}
}
}
}
} catch (e) {
_logger.warning('Audnexus author image error: $e', context: 'Metadata');
_logger.warning('Audnexus author image error for "$name": $e', context: 'Metadata');
}
return null;
}
// Fall back to Open Library API
/// Try to fetch author image from Open Library
static Future<String?> _tryOpenLibrary(String name) async {
try {
final uri = Uri.https(
'openlibrary.org',
'/search/authors.json',
{
'q': authorName,
'limit': '1',
},
);
final uri = Uri.https('openlibrary.org', '/search/authors.json', {
'q': name,
'limit': '1',
});
final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final authors = data['docs'] as List?;
if (authors != null && authors.isNotEmpty) {
final author = authors[0];
final authorKey = author['key'] as String?;
final authorKey = authors[0]['key'] as String?;
if (authorKey != null) {
// Open Library author images are available at:
// https://covers.openlibrary.org/a/olid/{OLID}-L.jpg
final olid = authorKey.replaceAll('/authors/', '');
final imageUrl = 'https://covers.openlibrary.org/a/olid/$olid-L.jpg';
// Verify the image actually exists (Open Library returns 404 for missing images)
// Verify image exists - Open Library returns 302 redirects for valid images
try {
final imageCheck = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
if (imageCheck.statusCode == 200) {
_authorImageCache[cacheKey] = imageUrl;
final check = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
// Accept 200 (direct), 301/302 (redirect to archive.org), or 307/308 (temporary redirect)
if (check.statusCode == 200 || check.statusCode == 301 ||
check.statusCode == 302 || check.statusCode == 307 || check.statusCode == 308) {
return imageUrl;
}
} catch (_) {
// Image doesn't exist, continue to cache null
} catch (_) {}
}
}
}
} catch (e) {
_logger.warning('Open Library author image error for "$name": $e', context: 'Metadata');
}
return null;
}
/// Try to fetch author image from Wikidata (structured Wikipedia data)
static Future<String?> _tryWikidata(String name) async {
try {
// Search for entity by name
final searchUri = Uri.https('www.wikidata.org', '/w/api.php', {
'action': 'wbsearchentities',
'search': name,
'language': 'en',
'type': 'item',
'limit': '3',
'format': 'json',
});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
final results = searchData['search'] as List?;
// Try each result to find one with an image
for (final result in results ?? []) {
final entityId = result['id'] as String?;
if (entityId == null) continue;
// Get the image property (P18)
final claimsUri = Uri.https('www.wikidata.org', '/w/api.php', {
'action': 'wbgetclaims',
'entity': entityId,
'property': 'P18', // P18 is the "image" property
'format': 'json',
});
final claimsResponse = await http.get(claimsUri).timeout(const Duration(seconds: 5));
if (claimsResponse.statusCode == 200) {
final claimsData = json.decode(claimsResponse.body);
final claims = claimsData['claims'] as Map<String, dynamic>?;
final imageProperty = claims?['P18'] as List?;
if (imageProperty != null && imageProperty.isNotEmpty) {
final imageName = imageProperty[0]['mainsnak']?['datavalue']?['value'] as String?;
if (imageName != null && imageName.isNotEmpty) {
// Convert filename to Wikimedia Commons URL
final encodedName = Uri.encodeComponent(imageName.replaceAll(' ', '_'));
return 'https://commons.wikimedia.org/wiki/Special:FilePath/$encodedName?width=500';
}
}
}
}
}
} catch (e) {
_logger.warning('Open Library author image error: $e', context: 'Metadata');
_logger.warning('Wikidata author image error for "$name": $e', context: 'Metadata');
}
return null;
}
// Fall back to Wikipedia API
/// Try to fetch author image from Wikipedia
static Future<String?> _tryWikipedia(String name) async {
try {
// Search for author on Wikipedia
final searchUri = Uri.https(
'en.wikipedia.org',
'/w/api.php',
{
'action': 'query',
'list': 'search',
'srsearch': '$authorName writer',
'srlimit': '1',
'format': 'json',
},
);
// Search for author - "writer author" suffix helps find the right page
final searchUri = Uri.https('en.wikipedia.org', '/w/api.php', {
'action': 'query',
'list': 'search',
'srsearch': '$name writer author',
'srlimit': '1',
'format': 'json',
});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
final searchResults = searchData['query']?['search'] as List?;
if (searchResults != null && searchResults.isNotEmpty) {
final pageTitle = searchResults[0]['title'] as String?;
final results = searchData['query']?['search'] as List?;
if (results != null && results.isNotEmpty) {
final pageTitle = results[0]['title'] as String?;
if (pageTitle != null) {
// Get page image
final imageUri = Uri.https(
'en.wikipedia.org',
'/w/api.php',
{
'action': 'query',
'titles': pageTitle,
'prop': 'pageimages',
'pithumbsize': '500',
'format': 'json',
},
);
final imageUri = Uri.https('en.wikipedia.org', '/w/api.php', {
'action': 'query',
'titles': pageTitle,
'prop': 'pageimages',
'pithumbsize': '500',
'format': 'json',
});
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
if (imageResponse.statusCode == 200) {
@@ -450,7 +631,6 @@ class MetadataService {
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
final imageUrl = thumbnail?['source'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
_authorImageCache[cacheKey] = imageUrl;
return imageUrl;
}
}
@@ -459,7 +639,100 @@ class MetadataService {
}
}
} catch (e) {
_logger.warning('Wikipedia author image error: $e', context: 'Metadata');
_logger.warning('Wikipedia author image error for "$name": $e', context: 'Metadata');
}
return null;
}
/// Fetches author image URL from multiple sources with fuzzy name matching
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
/// Tries name variations if exact match fails
static Future<String?> getAuthorImageUrl(String authorName) async {
// Check cache first
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) {
return _authorImageCache[cacheKey];
}
// Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
// Try to get image for the first/primary author
if (authorName.contains(',')) {
final authors = authorName.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
// Skip if this looks like "Lastname, Firstname" format or has suffixes
// - Second part is initials (J.R.R., J.K., etc.)
// - Second part is a suffix (Jr., Sr., III, PhD, etc.)
final suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'phd', 'ph.d.', 'md', 'm.d.'];
if (authors.length == 2) {
final secondPart = authors[1].toLowerCase();
// Check if second part is a suffix or looks like initials
if (suffixes.contains(secondPart) ||
RegExp(r'^[a-z]\.?\s*[a-z]?\.?\s*[a-z]?\.?$').hasMatch(secondPart)) {
// This is "Lastname, First" or "Name, Jr." - don't split
} else if (authors[0].split(' ').length > 1 && authors[1].split(' ').length > 1) {
// Both parts have multiple words - likely "Author1, Author2"
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// Don't cache null here - fall through to try the full name
}
} else if (authors.length > 2) {
// Multiple commas = definitely multiple authors
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// Don't cache null - fall through to try full name
}
}
// Generate name variations for fuzzy matching
final variations = _generateNameVariations(authorName);
// Try each source with all variations before moving to next source
// This prioritizes source quality over name variation
// 1. Try Audnexus (best for audiobook authors)
for (final name in variations) {
final result = await _tryAudnexus(name);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// 2. Try Open Library
for (final name in variations) {
final result = await _tryOpenLibrary(name);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// 3. Try Wikidata (structured data, good for finding exact person)
for (final name in variations.take(2)) {
final wikidataResult = await _tryWikidata(name);
if (wikidataResult != null) {
_authorImageCache[cacheKey] = wikidataResult;
return wikidataResult;
}
}
// 4. Try Wikipedia (search-based, good fallback)
for (final name in variations.take(3)) {
final wikiResult = await _tryWikipedia(name);
if (wikiResult != null) {
_authorImageCache[cacheKey] = wikiResult;
return wikiResult;
}
}
// Cache the null result to avoid repeated failed lookups
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'debug_logger.dart';
/// Represents a queued action that will be executed when connection is restored
class QueuedAction {
final String type;
final Map<String, dynamic> params;
final DateTime queuedAt;
QueuedAction({
required this.type,
required this.params,
required this.queuedAt,
});
Map<String, dynamic> toJson() => {
'type': type,
'params': params,
'queuedAt': queuedAt.toIso8601String(),
};
factory QueuedAction.fromJson(Map<String, dynamic> json) => QueuedAction(
type: json['type'] as String,
params: json['params'] as Map<String, dynamic>,
queuedAt: DateTime.parse(json['queuedAt'] as String),
);
}
/// Service for queuing actions when offline and executing them when back online
class OfflineActionQueue {
static OfflineActionQueue? _instance;
static OfflineActionQueue get instance => _instance ??= OfflineActionQueue._();
OfflineActionQueue._();
final _logger = DebugLogger();
static const _prefsKey = 'offline_action_queue';
List<QueuedAction> _queue = [];
bool _isProcessing = false;
/// Get the current queue (for display)
List<QueuedAction> get queue => List.unmodifiable(_queue);
/// Check if there are pending actions
bool get hasPendingActions => _queue.isNotEmpty;
/// Number of pending actions
int get pendingCount => _queue.length;
/// Initialize and load persisted queue
Future<void> initialize() async {
try {
final prefs = await SharedPreferences.getInstance();
final queueJson = prefs.getString(_prefsKey);
if (queueJson != null) {
final List<dynamic> decoded = jsonDecode(queueJson);
_queue = decoded.map((j) => QueuedAction.fromJson(j as Map<String, dynamic>)).toList();
_logger.log('📋 Loaded ${_queue.length} offline actions from storage');
}
} catch (e) {
_logger.log('⚠️ Error loading offline action queue: $e');
}
}
/// Queue an action for later execution
Future<void> queueAction(String type, Map<String, dynamic> params) async {
final action = QueuedAction(
type: type,
params: params,
queuedAt: DateTime.now(),
);
_queue.add(action);
await _persistQueue();
_logger.log('📋 Queued offline action: $type');
}
/// Process all queued actions (call when connection is restored)
Future<void> processQueue(Future<bool> Function(QueuedAction action) executor) async {
if (_isProcessing || _queue.isEmpty) return;
_isProcessing = true;
_logger.log('🔄 Processing ${_queue.length} offline actions...');
final processed = <QueuedAction>[];
for (final action in _queue) {
try {
final success = await executor(action);
if (success) {
processed.add(action);
_logger.log('✅ Processed offline action: ${action.type}');
} else {
_logger.log('⚠️ Failed to process offline action: ${action.type}');
}
} catch (e) {
_logger.log('❌ Error processing offline action: $e');
}
}
// Remove processed actions
_queue.removeWhere((a) => processed.contains(a));
await _persistQueue();
_isProcessing = false;
_logger.log('📋 ${processed.length} actions processed, ${_queue.length} remaining');
}
/// Clear all queued actions
Future<void> clearQueue() async {
_queue.clear();
await _persistQueue();
_logger.log('🗑️ Offline action queue cleared');
}
/// Persist queue to storage
Future<void> _persistQueue() async {
try {
final prefs = await SharedPreferences.getInstance();
if (_queue.isEmpty) {
await prefs.remove(_prefsKey);
} else {
final queueJson = jsonEncode(_queue.map((a) => a.toJson()).toList());
await prefs.setString(_prefsKey, queueJson);
}
} catch (e) {
_logger.log('⚠️ Error persisting offline action queue: $e');
}
}
}
/// Action types for offline queuing
class OfflineActionTypes {
static const String toggleFavorite = 'toggle_favorite';
static const String addToPlaylist = 'add_to_playlist';
static const String removeFromPlaylist = 'remove_from_playlist';
}
+19 -4
View File
@@ -83,6 +83,11 @@ class PcmAudioPlayer {
// Reduced from 8000 to 5000 frames (~104ms vs ~166ms) for faster response
static const int _feedThreshold = 5000;
// Maximum buffer size to prevent memory overflow (~10 seconds of audio)
// At 48kHz stereo 16-bit: 192KB/sec, so ~2MB for 10 seconds
// Each chunk is ~4KB, so max ~500 chunks
static const int _maxBufferChunks = 500;
// Stats
int _framesPlayed = 0;
int _bytesPlayed = 0;
@@ -201,6 +206,16 @@ class PcmAudioPlayer {
return true;
}
/// Safely add audio data to buffer with overflow protection
void _addToBuffer(Uint8List audioData) {
// If buffer is full, drop oldest chunks to make room
while (_audioBuffer.length >= _maxBufferChunks) {
_audioBuffer.removeAt(0);
_logger.log('⚠️ PcmAudioPlayer: Buffer overflow - dropping oldest chunk');
}
_audioBuffer.add(audioData);
}
/// Handle incoming audio data from the stream
void _onAudioData(Uint8List audioData) {
// Ignore data if in error state
@@ -215,7 +230,7 @@ class PcmAudioPlayer {
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
_isAutoRecovering = true;
_audioBuffer.add(audioData);
_addToBuffer(audioData);
// Trigger async recovery
_autoRecoverFromPause();
@@ -224,7 +239,7 @@ class PcmAudioPlayer {
// If already recovering or in transitional state, buffer the data
if (_isAutoRecovering) {
_audioBuffer.add(audioData);
_addToBuffer(audioData);
return;
}
@@ -232,8 +247,8 @@ class PcmAudioPlayer {
return;
}
// Add to buffer
_audioBuffer.add(audioData);
// Add to buffer with overflow protection
_addToBuffer(audioData);
// Start playback if not already started
if (!_isStarted && _state == PcmPlayerState.ready) {
+17 -2
View File
@@ -24,6 +24,10 @@ class PositionTracker {
double _anchorPosition = 0.0; // Position in seconds at anchor time
DateTime _anchorTime = DateTime.now(); // When we set the anchor
// Maximum time to interpolate from a stale anchor before capping
// After this, we stop adding time to prevent indefinite drift
static const Duration _maxAnchorAge = Duration(seconds: 30);
// Interpolation timer
Timer? _interpolationTimer;
@@ -43,7 +47,13 @@ class PositionTracker {
}
final now = DateTime.now();
final elapsed = now.difference(_anchorTime).inMilliseconds / 1000.0;
final anchorAge = now.difference(_anchorTime);
// Cap interpolation at max anchor age to prevent indefinite drift
// If anchor is stale, return last known position (anchor + max age)
final elapsed = anchorAge > _maxAnchorAge
? _maxAnchorAge.inMilliseconds / 1000.0
: anchorAge.inMilliseconds / 1000.0;
final interpolated = _anchorPosition + elapsed;
// Cap at duration to prevent overflow
@@ -61,7 +71,12 @@ class PositionTracker {
}
final now = DateTime.now();
final elapsed = now.difference(_anchorTime).inMilliseconds / 1000.0;
final anchorAge = now.difference(_anchorTime);
// Cap interpolation at max anchor age to prevent indefinite drift
final elapsed = anchorAge > _maxAnchorAge
? _maxAnchorAge.inMilliseconds / 1000.0
: anchorAge.inMilliseconds / 1000.0;
final interpolated = _anchorPosition + elapsed;
// Cap at duration
+18 -2
View File
@@ -25,6 +25,8 @@ class RecentlyPlayedService {
if (!_db.isInitialized) return;
try {
// Store images metadata for instant artwork display
final images = album.metadata?['images'];
await _db.addRecentlyPlayed(
mediaId: album.itemId,
mediaType: 'album',
@@ -33,6 +35,7 @@ class RecentlyPlayedService {
metadata: {
'provider': album.provider,
'uri': album.uri,
if (images != null) 'images': images,
},
);
_logger.log('📝 Recorded album play: ${album.name}');
@@ -148,7 +151,16 @@ class RecentlyPlayedService {
if (item.metadata != null) {
try {
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
} catch (_) {}
} catch (e) {
// Corrupted metadata JSON - log and continue with null metadata
_logger.log('⚠️ Error parsing album metadata for ${item.mediaId}: $e');
}
}
// Build metadata with images if available (for instant artwork display)
Map<String, dynamic>? albumMetadata;
if (metadata?['images'] != null) {
albumMetadata = {'images': metadata!['images']};
}
albums.add(Album(
@@ -160,6 +172,7 @@ class RecentlyPlayedService {
artists: item.artistName != null
? [Artist(itemId: '', provider: '', name: item.artistName!)]
: null,
metadata: albumMetadata,
));
if (albums.length >= limit) break;
@@ -193,7 +206,10 @@ class RecentlyPlayedService {
if (item.metadata != null) {
try {
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
} catch (_) {}
} catch (e) {
// Corrupted metadata JSON - log and continue with null metadata
_logger.log('⚠️ Error parsing audiobook metadata for ${item.mediaId}: $e');
}
}
audiobooks.add(Audiobook(
+211
View File
@@ -0,0 +1,211 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
/// Service for securely storing sensitive data like passwords and tokens.
/// Uses platform-specific secure storage (Keychain on iOS, EncryptedSharedPreferences on Android).
class SecureStorageService {
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
);
// Keys for secure storage
static const String _keyAuthToken = 'secure_auth_token';
static const String _keyMaAuthToken = 'secure_ma_auth_token';
static const String _keyAuthCredentials = 'secure_auth_credentials';
static const String _keyPassword = 'secure_password';
static const String _keyAbsApiToken = 'secure_abs_api_token';
// Auth Token (for stream requests)
static Future<String?> getAuthToken() async {
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 {
if (token == null || token.isEmpty) {
await _storage.delete(key: _keyAuthToken);
} else {
await _storage.write(key: _keyAuthToken, value: token);
}
}
// Music Assistant Native Auth Token (long-lived token)
static Future<String?> getMaAuthToken() async {
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 {
if (token == null || token.isEmpty) {
await _storage.delete(key: _keyMaAuthToken);
} else {
await _storage.write(key: _keyMaAuthToken, value: token);
}
}
static Future<void> clearMaAuthToken() async {
await _storage.delete(key: _keyMaAuthToken);
}
// Auth Credentials (serialized auth strategy credentials)
static Future<Map<String, dynamic>?> getAuthCredentials() async {
String? json;
try {
json = await _storage.read(key: _keyAuthCredentials);
} catch (e) {
// Handle decryption errors (e.g., after keystore/signature change)
if (e.toString().contains('BadPaddingException') ||
e.toString().contains('BAD_DECRYPT')) {
await _storage.delete(key: _keyAuthCredentials);
return null;
}
rethrow;
}
if (json == null) return null;
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (e) {
return null;
}
}
static Future<void> setAuthCredentials(Map<String, dynamic> credentials) async {
await _storage.write(key: _keyAuthCredentials, value: jsonEncode(credentials));
}
static Future<void> clearAuthCredentials() async {
await _storage.delete(key: _keyAuthCredentials);
}
// Password
static Future<String?> getPassword() async {
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 {
if (password == null || password.isEmpty) {
await _storage.delete(key: _keyPassword);
} else {
await _storage.write(key: _keyPassword, value: password);
}
}
// Audiobookshelf API Token
static Future<String?> getAbsApiToken() async {
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 {
if (token == null || token.isEmpty) {
await _storage.delete(key: _keyAbsApiToken);
} else {
await _storage.write(key: _keyAbsApiToken, value: token);
}
}
/// Clear all secure storage (used during logout)
static Future<void> clearAll() async {
await _storage.deleteAll();
}
// Migration completed flag key
static const String _keyMigrationCompleted = 'secure_storage_migration_completed';
/// Migrate credentials from SharedPreferences to secure storage.
/// Call this once during app upgrade to migrate existing users.
/// Uses a flag to skip migration on subsequent runs for performance.
static Future<void> migrateFromSharedPreferences() async {
// Import SharedPreferences for migration
final SharedPreferences prefs = await SharedPreferences.getInstance();
// Skip if migration already completed
if (prefs.getBool(_keyMigrationCompleted) == true) {
return;
}
// Migrate auth token
final authToken = prefs.getString('auth_token');
if (authToken != null && authToken.isNotEmpty) {
await setAuthToken(authToken);
await prefs.remove('auth_token');
}
// Migrate MA auth token
final maAuthToken = prefs.getString('ma_auth_token');
if (maAuthToken != null && maAuthToken.isNotEmpty) {
await setMaAuthToken(maAuthToken);
await prefs.remove('ma_auth_token');
}
// Migrate auth credentials
final authCredentials = prefs.getString('auth_credentials');
if (authCredentials != null && authCredentials.isNotEmpty) {
try {
final decoded = jsonDecode(authCredentials) as Map<String, dynamic>;
await setAuthCredentials(decoded);
await prefs.remove('auth_credentials');
} catch (_) {
// Invalid JSON, just remove it
await prefs.remove('auth_credentials');
}
}
// Migrate password
final password = prefs.getString('password');
if (password != null && password.isNotEmpty) {
await setPassword(password);
await prefs.remove('password');
}
// Migrate ABS API token
final absApiToken = prefs.getString('abs_api_token');
if (absApiToken != null && absApiToken.isNotEmpty) {
await setAbsApiToken(absApiToken);
await prefs.remove('abs_api_token');
}
// Mark migration as completed to skip on future runs
await prefs.setBool(_keyMigrationCompleted, true);
}
}
+36 -7
View File
@@ -87,6 +87,9 @@ class SendspinService {
// Auth token for proxy authentication (MA 2.7.1+)
String? _authToken;
// Connection deduplication guard - prevents multiple concurrent auth attempts
Completer<bool>? _connectionInProgress;
SendspinService(this.serverUrl);
/// Set the MA auth token for proxy authentication
@@ -100,6 +103,13 @@ class SendspinService {
if (_isDisposed) return false;
if (_state == SendspinConnectionState.connected) return true;
// Deduplicate concurrent connection attempts
if (_connectionInProgress != null) {
_logger.log('Sendspin: Connection already in progress, waiting...');
return _connectionInProgress!.future;
}
_connectionInProgress = Completer<bool>();
_updateState(SendspinConnectionState.connecting);
try {
@@ -120,20 +130,25 @@ class SendspinService {
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
}
// Strategy 2: If external failed or server is HTTP, try local connection
// Note: We would need local_ws_url from getSendspinConnectionInfo API
// For now, skip this step and fall back to WebRTC (handled by provider)
// Note: Local network connections (ws://ip:8927/sendspin) are handled
// by the provider calling connectWithUrl() directly, not by this method.
if (!connected) {
_logger.log('Sendspin: External connection failed, WebRTC fallback needed');
_logger.log('Sendspin: External proxy connection failed');
_updateState(SendspinConnectionState.error);
_connectionInProgress?.complete(false);
_connectionInProgress = null;
return false;
}
_connectionInProgress?.complete(true);
_connectionInProgress = null;
return true;
} catch (e) {
_logger.log('Sendspin: Connection error: $e');
_updateState(SendspinConnectionState.error);
_connectionInProgress?.complete(false);
_connectionInProgress = null;
return false;
}
}
@@ -143,6 +158,13 @@ class SendspinService {
if (_isDisposed) return false;
if (_state == SendspinConnectionState.connected) return true;
// Deduplicate concurrent connection attempts
if (_connectionInProgress != null) {
_logger.log('Sendspin: Connection already in progress, waiting...');
return _connectionInProgress!.future;
}
_connectionInProgress = Completer<bool>();
_updateState(SendspinConnectionState.connecting);
try {
@@ -155,13 +177,19 @@ class SendspinService {
if (!connected) {
_updateState(SendspinConnectionState.error);
_connectionInProgress?.complete(false);
_connectionInProgress = null;
return false;
}
_connectionInProgress?.complete(true);
_connectionInProgress = null;
return true;
} catch (e) {
_logger.log('Sendspin: Connection error: $e');
_updateState(SendspinConnectionState.error);
_connectionInProgress?.complete(false);
_connectionInProgress = null;
return false;
}
}
@@ -519,8 +547,9 @@ class SendspinService {
if (muted != null) _isMuted = muted;
// Determine player state for Sendspin protocol
// 'synchronized' = ready/playing, 'idle' = not playing
final playerState = _isPlaying || _isPaused ? 'synchronized' : 'idle';
// Valid values: 'synchronized', 'error', 'external_source'
// When connected, we're always synchronized (ready to receive audio)
const playerState = 'synchronized';
_sendMessage({
'type': 'client/state',
@@ -581,7 +610,7 @@ class SendspinService {
_sendMessage({
'type': 'client/time',
'payload': {
'timestamp': timestampMicroseconds,
'client_transmitted': timestampMicroseconds,
},
}, allowDuringHandshake: allowDuringHandshake);
}
File diff suppressed because it is too large Load Diff
+23 -8
View File
@@ -5,6 +5,7 @@ import '../models/media_item.dart';
import 'database_service.dart';
import 'debug_logger.dart';
import 'music_assistant_api.dart';
import 'settings_service.dart';
/// Sync status for UI indicators
enum SyncStatus {
@@ -118,7 +119,8 @@ class SyncService with ChangeNotifier {
/// Sync library data from MA API in background
/// Updates database cache and notifies listeners when complete
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false}) async {
/// If provider is specified, only fetch items from that provider (server-side filtering)
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false, String? provider}) async {
if (_isSyncing) {
_logger.log('🔄 Sync already in progress, skipping');
return;
@@ -148,12 +150,17 @@ class SyncService with ChangeNotifier {
notifyListeners();
try {
_logger.log('🔄 Starting background library sync...');
_logger.log('🔄 Starting background library sync (provider=$provider)...');
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums, provider: $provider');
// Fetch fresh data from MA API (in parallel for speed)
// If provider is specified, filter to only that provider's library items
final results = await Future.wait([
api.getAlbums(limit: 1000),
api.getArtists(limit: 1000),
api.getAlbums(limit: 1000, provider: provider),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, provider: provider),
api.getAudiobooks(limit: 1000),
api.getPlaylists(limit: 1000),
]);
@@ -166,6 +173,12 @@ class SyncService with ChangeNotifier {
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
// Clear old cache before saving fresh data (removes stale items)
await _db.clearCacheForType('album');
await _db.clearCacheForType('artist');
await _db.clearCacheForType('audiobook');
await _db.clearCacheForType('playlist');
// Save to database cache
await _saveAlbumsToCache(albums);
await _saveArtistsToCache(artists);
@@ -262,11 +275,12 @@ class SyncService with ChangeNotifier {
}
/// Force a fresh sync (for pull-to-refresh)
Future<void> forceSync(MusicAssistantAPI api) async {
await syncFromApi(api, force: true);
Future<void> forceSync(MusicAssistantAPI api, {String? provider}) async {
await syncFromApi(api, force: true, provider: provider);
}
/// Clear all cached data
/// Clear all cached data and reset sync state
/// This allows a fresh sync to start even if one was in progress
Future<void> clearCache() async {
if (!_db.isInitialized) return;
@@ -276,9 +290,10 @@ class SyncService with ChangeNotifier {
_cachedAudiobooks = [];
_cachedPlaylists = [];
_lastSyncTime = null;
_isSyncing = false; // Reset so a new sync can start
_status = SyncStatus.idle;
notifyListeners();
_logger.log('🗑️ Library cache cleared');
_logger.log('🗑️ Library cache cleared (sync state reset)');
}
/// Get albums (from cache or empty if not loaded)
+32 -1
View File
@@ -4,7 +4,17 @@ import 'package:flutter/material.dart';
const Color brandPrimaryColor = Color(0xFF1a1a1a);
const Color brandAccentColor = Color(0xFF604CEC);
// Brand color schemes
// Predefined accent colors for custom theme selection
const List<Color> accentColorOptions = [
Color(0xFF604CEC), // Purple (default)
Color(0xFF2196F3), // Blue
Color(0xFF009688), // Teal
Color(0xFFFF5722), // Deep Orange
Color(0xFFE91E63), // Pink
Color(0xFF4CAF50), // Green
];
// Brand color schemes (default)
final ColorScheme brandLightColorScheme = ColorScheme.fromSeed(
seedColor: brandAccentColor,
brightness: Brightness.light,
@@ -21,6 +31,27 @@ final ColorScheme brandDarkColorScheme = ColorScheme.fromSeed(
background: const Color(0xFF1a1a1a),
);
// Generate color schemes from a custom seed color
ColorScheme generateLightColorScheme(Color seedColor) {
return ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.light,
background: const Color(0xFFFAFAFA),
onBackground: const Color(0xFF1a1a1a),
surface: const Color(0xFFFFFFFF),
onSurface: const Color(0xFF1a1a1a),
);
}
ColorScheme generateDarkColorScheme(Color seedColor) {
return ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
surface: const Color(0xFF2a2a2a),
background: const Color(0xFF1a1a1a),
);
}
class AppTheme {
// Generate light theme
static ThemeData lightTheme({ColorScheme? colorScheme}) {
+48
View File
@@ -124,3 +124,51 @@ class Durations {
static const Duration normal = Duration(milliseconds: 300);
static const Duration slow = Duration(milliseconds: 500);
}
/// Button size tokens
class ButtonSizes {
ButtonSizes._();
// Touch target sizes (Material Design minimum: 48x48)
static const double sm = 32.0;
static const double md = 36.0;
static const double lg = 44.0;
static const double xl = 48.0;
static const double xxl = 56.0;
// Icon sizes within buttons
static const double iconSm = 16.0;
static const double iconMd = 18.0;
static const double iconLg = 20.0;
static const double iconXl = 24.0;
}
/// Semantic status colors for consistent feedback
/// Use with Theme brightness for proper contrast
class StatusColors {
StatusColors._();
// Success states
static const Color success = Color(0xFF4CAF50);
static const Color successLight = Color(0xFF81C784);
static const Color successDark = Color(0xFF388E3C);
// Error states
static const Color error = Color(0xFFF44336);
static const Color errorLight = Color(0xFFE57373);
static const Color errorDark = Color(0xFFD32F2F);
// Warning states
static const Color warning = Color(0xFFFF9800);
static const Color warningLight = Color(0xFFFFB74D);
static const Color warningDark = Color(0xFFF57C00);
// Info states
static const Color info = Color(0xFF2196F3);
static const Color infoLight = Color(0xFF64B5F6);
static const Color infoDark = Color(0xFF1976D2);
// Favorites (heart icon)
static const Color favorite = Color(0xFFF44336);
static const Color favoriteActive = Color(0xFFE53935);
}
+33 -3
View File
@@ -9,6 +9,7 @@ final _themeLogger = DebugLogger();
/// Global function to update adaptive colors from an image URL
/// This can be called from anywhere in the app (e.g., when tapping an album/artist)
/// Sets isOnDetailScreen=true so nav bar will also use adaptive colors
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return;
@@ -16,7 +17,7 @@ Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUr
final colorSchemes = await PaletteHelper.extractColorSchemes(CachedNetworkImageProvider(imageUrl));
if (colorSchemes != null && context.mounted) {
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2);
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
// Silently fail - colors will update when track plays
@@ -42,6 +43,10 @@ class ThemeProvider extends ChangeNotifier {
ColorScheme? _adaptiveLightScheme;
ColorScheme? _adaptiveDarkScheme;
// Track if we're on a detail screen (vs just player-extracted colors)
// When true, nav bar should use adaptive colors even when player is collapsed
bool _isOnDetailScreen = false;
ThemeProvider() {
_loadSettings();
}
@@ -55,12 +60,14 @@ class ThemeProvider extends ChangeNotifier {
AdaptiveColors? get adaptiveColors => _adaptiveColors;
ColorScheme? get adaptiveLightScheme => _adaptiveLightScheme;
ColorScheme? get adaptiveDarkScheme => _adaptiveDarkScheme;
bool get isOnDetailScreen => _isOnDetailScreen;
/// Get the current adaptive primary color (for bottom nav highlight, etc.)
Color get adaptivePrimaryColor => _adaptiveColors?.primary ?? _customColor;
/// Get the current adaptive surface color (for bottom nav background, etc.)
/// Returns a subtle tinted surface based on the adaptive colors
/// NOTE: Prefer getAdaptiveSurfaceColorFor() which respects light/dark mode
Color? get adaptiveSurfaceColor {
if (_adaptiveColors == null) return null;
// Use the miniPlayer color but darkened for a subtle tinted background
@@ -68,6 +75,20 @@ class ThemeProvider extends ChangeNotifier {
return hsl.withLightness((hsl.lightness * 0.4).clamp(0.08, 0.15)).toColor();
}
/// Get adaptive surface color for the given brightness (light/dark mode aware)
/// Returns the same color as the expanded player background (scheme.surface)
Color? getAdaptiveSurfaceColorFor(Brightness brightness) {
// Pick scheme based on current mode
final scheme = brightness == Brightness.dark
? _adaptiveDarkScheme
: _adaptiveLightScheme;
if (scheme == null) return null;
// Use surface - same as expanded player background (line 1234 in expandable_player.dart)
return scheme.surface;
}
Future<void> _loadSettings() async {
final themeModeString = await SettingsService.getThemeMode();
_themeMode = _parseThemeMode(themeModeString);
@@ -147,10 +168,18 @@ class ThemeProvider extends ChangeNotifier {
}
/// Update adaptive colors from album art
void updateAdaptiveColors(ColorScheme? lightScheme, ColorScheme? darkScheme) {
/// [isFromDetailScreen] - if true, this is from navigating to a detail screen
/// and the nav bar should use adaptive colors even when player is collapsed
void updateAdaptiveColors(ColorScheme? lightScheme, ColorScheme? darkScheme, {bool isFromDetailScreen = false}) {
_adaptiveLightScheme = lightScheme;
_adaptiveDarkScheme = darkScheme;
// Only set detail screen flag if explicitly from a detail screen navigation
// Player-extracted colors should NOT set this flag
if (isFromDetailScreen) {
_isOnDetailScreen = true;
}
// Extract AdaptiveColors from the schemes
if (darkScheme != null) {
_adaptiveColors = AdaptiveColors(
@@ -166,11 +195,12 @@ class ThemeProvider extends ChangeNotifier {
notifyListeners();
}
/// Clear adaptive colors (when no track is playing)
/// Clear adaptive colors (when navigating back from detail screen or no track playing)
void clearAdaptiveColors() {
_adaptiveColors = null;
_adaptiveLightScheme = null;
_adaptiveDarkScheme = null;
_isOnDetailScreen = false;
notifyListeners();
}
}
+137
View File
@@ -0,0 +1,137 @@
import 'dart:math';
/// Fuzzy string matching using Jaro-Winkler similarity.
///
/// Jaro-Winkler is optimized for short strings like artist/song names
/// and gives extra weight to matching prefixes (common typos occur mid-word).
class FuzzyMatcher {
/// Calculate Jaro-Winkler similarity between two strings.
///
/// Returns a value between 0.0 (no similarity) and 1.0 (exact match).
///
/// Jaro-Winkler gives bonus weight to strings that match from the beginning,
/// which is useful for handling typos like "Beetles" vs "Beatles".
double jaroWinklerSimilarity(String s1, String s2) {
if (s1 == s2) return 1.0;
if (s1.isEmpty || s2.isEmpty) return 0.0;
final jaro = _jaroSimilarity(s1, s2);
// Winkler modification: boost score for common prefix
int prefixLength = 0;
const maxPrefix = 4; // Standard Winkler prefix length
final minLen = min(s1.length, s2.length);
final prefixLimit = min(maxPrefix, minLen);
for (int i = 0; i < prefixLimit; i++) {
if (s1[i] == s2[i]) {
prefixLength++;
} else {
break;
}
}
const scalingFactor = 0.1; // Standard Winkler scaling factor
return jaro + (prefixLength * scalingFactor * (1 - jaro));
}
/// Calculate base Jaro similarity.
double _jaroSimilarity(String s1, String s2) {
final s1Len = s1.length;
final s2Len = s2.length;
// Match window: characters within this distance can match
final matchWindow = (max(s1Len, s2Len) / 2 - 1).floor();
if (matchWindow < 0) return 0.0;
final s1Matches = List.filled(s1Len, false);
final s2Matches = List.filled(s2Len, false);
int matches = 0;
int transpositions = 0;
// Find matching characters
for (int i = 0; i < s1Len; i++) {
final start = max(0, i - matchWindow);
final end = min(s2Len, i + matchWindow + 1);
for (int j = start; j < end; j++) {
if (s2Matches[j] || s1[i] != s2[j]) continue;
s1Matches[i] = true;
s2Matches[j] = true;
matches++;
break;
}
}
if (matches == 0) return 0.0;
// Count transpositions
int k = 0;
for (int i = 0; i < s1Len; i++) {
if (!s1Matches[i]) continue;
while (!s2Matches[k]) {
k++;
}
if (s1[i] != s2[k]) transpositions++;
k++;
}
// Jaro similarity formula
return (matches / s1Len +
matches / s2Len +
(matches - transpositions / 2) / matches) /
3;
}
/// Find the best match score between query tokens and text tokens.
///
/// Useful for matching individual words when full string matching fails.
/// Returns the highest similarity score found between any token pair.
double bestTokenMatch(List<String> queryTokens, List<String> textTokens) {
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
double bestScore = 0.0;
for (final queryToken in queryTokens) {
for (final textToken in textTokens) {
final score = jaroWinklerSimilarity(queryToken, textToken);
if (score > bestScore) {
bestScore = score;
// Early exit if we find a perfect match
if (score == 1.0) return 1.0;
}
}
}
return bestScore;
}
/// Check if two strings are a fuzzy match within a threshold.
bool isFuzzyMatch(String s1, String s2, {double threshold = 0.85}) {
return jaroWinklerSimilarity(s1, s2) >= threshold;
}
/// Calculate average token match score.
///
/// For each query token, finds the best matching text token,
/// then averages all best matches. Useful for multi-word queries.
double averageTokenMatch(List<String> queryTokens, List<String> textTokens) {
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
double totalScore = 0.0;
for (final queryToken in queryTokens) {
double bestForToken = 0.0;
for (final textToken in textTokens) {
final score = jaroWinklerSimilarity(queryToken, textToken);
if (score > bestForToken) {
bestForToken = score;
}
}
totalScore += bestForToken;
}
return totalScore / queryTokens.length;
}
}
+96
View File
@@ -0,0 +1,96 @@
/// N-gram based string matching for partial matches.
///
/// Uses character bigrams (2-grams) to find partial string matches
/// when exact and fuzzy matching fail.
class NgramMatcher {
/// Generate character bigrams from text.
///
/// Example: "hello" -> ["he", "el", "ll", "lo"]
///
/// For strings shorter than 2 characters, returns the string itself.
List<String> generateBigrams(String text) {
if (text.length < 2) return text.isNotEmpty ? [text] : [];
final bigrams = <String>[];
for (int i = 0; i < text.length - 1; i++) {
bigrams.add(text.substring(i, i + 2));
}
return bigrams;
}
/// Generate character trigrams from text.
///
/// Example: "hello" -> ["hel", "ell", "llo"]
List<String> generateTrigrams(String text) {
if (text.length < 3) return text.isNotEmpty ? [text] : [];
final trigrams = <String>[];
for (int i = 0; i < text.length - 2; i++) {
trigrams.add(text.substring(i, i + 3));
}
return trigrams;
}
/// Calculate bigram similarity using Dice coefficient.
///
/// Dice coefficient: 2 * |intersection| / (|set1| + |set2|)
/// Returns a value between 0.0 and 1.0.
double bigramSimilarity(String s1, String s2) {
final lower1 = s1.toLowerCase();
final lower2 = s2.toLowerCase();
final bigrams1 = generateBigrams(lower1);
final bigrams2 = generateBigrams(lower2);
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
final set1 = bigrams1.toSet();
final set2 = bigrams2.toSet();
final intersection = set1.intersection(set2).length;
// Dice coefficient
return (2 * intersection) / (set1.length + set2.length);
}
/// Calculate Jaccard similarity between bigram sets.
///
/// Jaccard: |intersection| / |union|
/// More conservative than Dice coefficient.
double jaccardSimilarity(String s1, String s2) {
final lower1 = s1.toLowerCase();
final lower2 = s2.toLowerCase();
final bigrams1 = generateBigrams(lower1).toSet();
final bigrams2 = generateBigrams(lower2).toSet();
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
final intersection = bigrams1.intersection(bigrams2).length;
final union = bigrams1.union(bigrams2).length;
return intersection / union;
}
/// Check if query partially matches text via bigrams.
///
/// Uses Dice coefficient with configurable threshold.
bool hasPartialMatch(String query, String text, {double threshold = 0.5}) {
return bigramSimilarity(query, text) >= threshold;
}
/// Calculate containment coefficient.
///
/// Measures how many of the query's bigrams are in the text.
/// Useful when query is expected to be shorter than text.
/// Returns |intersection| / |query bigrams|
double containmentScore(String query, String text) {
final queryBigrams = generateBigrams(query.toLowerCase()).toSet();
final textBigrams = generateBigrams(text.toLowerCase()).toSet();
if (queryBigrams.isEmpty) return 0.0;
final intersection = queryBigrams.intersection(textBigrams).length;
return intersection / queryBigrams.length;
}
}
+86
View File
@@ -0,0 +1,86 @@
/// Configuration for search scoring weights and thresholds.
///
/// All scores are designed to work with the existing sorting system
/// where higher scores indicate better matches.
class ScoringConfig {
// Base scores for match types (primary name matching)
final double exactMatch;
final double exactMatchNoStopwords;
final double startsWithMatch;
final double startsWithNoStopwords;
final double wordBoundaryMatch;
final double wordBoundaryNoStopwords;
final double reverseContainsMatch;
final double reverseContainsNoStopwords;
final double containsMatch;
final double containsNoStopwords;
final double fuzzyMatchHigh;
final double fuzzyMatchMedium;
final double ngramMatch;
final double baseline;
// Bonuses (additive)
final double libraryBonus;
final double favoriteBonus;
final double artistFieldExactBonus;
final double artistFieldPartialBonus;
final double albumFieldBonus;
final double authorFieldExactBonus;
final double authorFieldPartialBonus;
final double narratorFieldBonus;
final double creatorFieldExactBonus;
final double creatorFieldPartialBonus;
final double descriptionBonus;
// Thresholds
final double fuzzyHighThreshold;
final double fuzzyMediumThreshold;
final double ngramThreshold;
// Minimum lengths for matching
final int minReverseMatchLength;
final int minTokenLength;
const ScoringConfig({
// Primary match scores
this.exactMatch = 100,
this.exactMatchNoStopwords = 95,
this.startsWithMatch = 85,
this.startsWithNoStopwords = 80,
this.wordBoundaryMatch = 70,
this.wordBoundaryNoStopwords = 65,
this.reverseContainsMatch = 60,
this.reverseContainsNoStopwords = 55,
this.containsMatch = 50,
this.containsNoStopwords = 45,
this.fuzzyMatchHigh = 40,
this.fuzzyMatchMedium = 35,
this.ngramMatch = 25,
this.baseline = 20,
// Bonuses
this.libraryBonus = 10,
this.favoriteBonus = 5,
this.artistFieldExactBonus = 15,
this.artistFieldPartialBonus = 8,
this.albumFieldBonus = 5,
this.authorFieldExactBonus = 15,
this.authorFieldPartialBonus = 8,
this.narratorFieldBonus = 5,
this.creatorFieldExactBonus = 15,
this.creatorFieldPartialBonus = 8,
this.descriptionBonus = 5,
// Thresholds
this.fuzzyHighThreshold = 0.90,
this.fuzzyMediumThreshold = 0.85,
this.ngramThreshold = 0.5,
// Minimum lengths
this.minReverseMatchLength = 3,
this.minTokenLength = 3,
});
/// Default configuration
static const ScoringConfig defaults = ScoringConfig();
}
+333
View File
@@ -0,0 +1,333 @@
/// Search scoring system for ranking search results.
///
/// Provides advanced text matching including:
/// - Stopword removal ("the Ramones" finds "Ramones")
/// - Fuzzy matching (typo tolerance)
/// - N-gram matching (partial matches)
/// - Reverse matching (query contains result)
library;
export 'scoring_config.dart';
export 'text_normalizer.dart';
export 'fuzzy_matcher.dart';
export 'ngram_matcher.dart';
import 'dart:math';
import '../../models/media_item.dart';
import 'scoring_config.dart';
import 'text_normalizer.dart';
import 'fuzzy_matcher.dart';
import 'ngram_matcher.dart';
/// Main search scorer that combines all matching strategies.
///
/// Usage:
/// ```dart
/// final scorer = SearchScorer();
/// final score = scorer.scoreItem(mediaItem, query);
/// ```
class SearchScorer {
final TextNormalizer _normalizer;
final FuzzyMatcher _fuzzyMatcher;
final NgramMatcher _ngramMatcher;
final ScoringConfig _config;
// Cache for normalized query (reused across items in same search)
String? _cachedQueryString;
NormalizedQuery? _cachedQuery;
SearchScorer({ScoringConfig? config})
: _normalizer = TextNormalizer(),
_fuzzyMatcher = FuzzyMatcher(),
_ngramMatcher = NgramMatcher(),
_config = config ?? ScoringConfig.defaults;
/// Access to the normalizer for external use
TextNormalizer get normalizer => _normalizer;
/// Clear the query cache (call when starting a new search)
void clearCache() {
_cachedQueryString = null;
_cachedQuery = null;
}
/// Score a media item against a search query.
///
/// Returns a score where higher values indicate better matches.
/// Score components:
/// - Primary name matching (0-100)
/// - Secondary field bonuses (0-20)
/// - Library/favorite bonuses (0-15)
double scoreItem(MediaItem item, String query) {
// Use cached normalized query if available
if (_cachedQueryString != query) {
_cachedQueryString = query;
_cachedQuery = _normalizer.normalizeQuery(query);
}
final nq = _cachedQuery!;
if (nq.isEmpty) return 0;
final nameLower = item.name.toLowerCase();
final nameNoStopwords = _normalizer.normalizeTextNoStopwords(item.name);
// Calculate primary score based on name matching
double score = _calculatePrimaryScore(nameLower, nameNoStopwords, nq);
// Add secondary field bonuses based on media type
score += _calculateSecondaryScore(item, nq);
// Add library/favorite bonuses
score += _calculateBonuses(item);
return score;
}
/// Calculate primary score based on name matching.
double _calculatePrimaryScore(
String nameLower,
String nameNoStopwords,
NormalizedQuery nq,
) {
// Tier 1: Exact match (highest priority)
if (nameLower == nq.normalized) {
return _config.exactMatch;
}
if (nameNoStopwords == nq.withoutStopwords) {
return _config.exactMatchNoStopwords;
}
// Tier 2: Starts with
if (nameLower.startsWith(nq.normalized)) {
return _config.startsWithMatch;
}
if (nameNoStopwords.startsWith(nq.withoutStopwords)) {
return _config.startsWithNoStopwords;
}
// Tier 3: Word boundary match
if (_matchesWordBoundary(nameLower, nq.normalized)) {
return _config.wordBoundaryMatch;
}
if (_matchesWordBoundary(nameNoStopwords, nq.withoutStopwords)) {
return _config.wordBoundaryNoStopwords;
}
// Tier 4: Reverse contains (result name IN query)
// This solves "the ramones" finding "Ramones"
if (_reverseContains(nq.normalized, nameLower)) {
return _config.reverseContainsMatch;
}
if (_reverseContains(nq.withoutStopwords, nameNoStopwords)) {
return _config.reverseContainsNoStopwords;
}
// Tier 5: Contains anywhere
if (nameLower.contains(nq.normalized)) {
return _config.containsMatch;
}
if (nameNoStopwords.contains(nq.withoutStopwords)) {
return _config.containsNoStopwords;
}
// Tier 6: Fuzzy matching (typo tolerance)
final fuzzyScore = _fuzzyMatcher.jaroWinklerSimilarity(
nq.withoutStopwords,
nameNoStopwords,
);
if (fuzzyScore >= _config.fuzzyHighThreshold) {
// Scale score based on similarity (0.90-1.0 -> 40-45)
return _config.fuzzyMatchHigh +
((fuzzyScore - _config.fuzzyHighThreshold) * 50);
}
if (fuzzyScore >= _config.fuzzyMediumThreshold) {
return _config.fuzzyMatchMedium;
}
// Tier 7: Token-level fuzzy (individual words)
final tokenFuzzy = _fuzzyMatcher.bestTokenMatch(
nq.tokensNoStop,
_normalizer.tokenize(nameNoStopwords),
);
if (tokenFuzzy >= _config.fuzzyHighThreshold) {
return _config.fuzzyMatchMedium;
}
// Tier 8: N-gram partial matching
final ngramScore = _ngramMatcher.bigramSimilarity(
nq.withoutStopwords,
nameNoStopwords,
);
if (ngramScore >= _config.ngramThreshold) {
// Scale: 0.5-1.0 -> 25-35
return _config.ngramMatch + (ngramScore * 10);
}
// Baseline: Music Assistant API returned it, so some relevance
return _config.baseline;
}
/// Check if query matches at word boundary in text.
bool _matchesWordBoundary(String text, String query) {
if (query.contains(' ')) {
// Multi-word query: check if at start or after space
if (text.startsWith(query)) return true;
if (text.contains(' $query')) return true;
return false;
}
// Single-word query: check if any word starts with query
final words = text.split(RegExp(r'\s+'));
for (final word in words) {
if (word.startsWith(query)) return true;
}
return false;
}
/// Check if text is contained within query (reverse matching).
///
/// Solves: "the ramones" query finding "Ramones" artist
bool _reverseContains(String query, String text) {
if (text.length < _config.minReverseMatchLength) return false;
// Direct containment
if (query.contains(text)) return true;
// Token-level: any query token equals text
final queryTokens = query.split(RegExp(r'\s+'));
for (final token in queryTokens) {
if (token.length >= _config.minReverseMatchLength && text == token) {
return true;
}
}
return false;
}
/// Calculate secondary field bonuses based on media type.
double _calculateSecondaryScore(MediaItem item, NormalizedQuery nq) {
double bonus = 0;
if (item is Album) {
bonus += _scoreArtistField(item.artistsString, nq);
} else if (item is Track) {
bonus += _scoreArtistField(item.artistsString, nq);
// Also check album name
if (item.album?.name != null) {
final albumLower = item.album!.name.toLowerCase();
if (albumLower.contains(nq.withoutStopwords)) {
bonus += _config.albumFieldBonus;
}
}
} else if (item is Audiobook) {
// Check authors
final authorLower = item.authorsString.toLowerCase();
if (authorLower == nq.withoutStopwords) {
bonus += _config.authorFieldExactBonus;
} else if (authorLower.contains(nq.withoutStopwords)) {
bonus += _config.authorFieldPartialBonus;
}
// Check narrators
final narratorLower = item.narratorsString.toLowerCase();
if (narratorLower.contains(nq.withoutStopwords)) {
bonus += _config.narratorFieldBonus;
}
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
bonus += _scorePodcastFields(item, nq);
}
return bonus;
}
/// Score artist field for albums and tracks.
double _scoreArtistField(String artistsString, NormalizedQuery nq) {
final artistLower = artistsString.toLowerCase();
if (artistLower == nq.withoutStopwords) {
return _config.artistFieldExactBonus;
}
if (artistLower.contains(nq.withoutStopwords)) {
return _config.artistFieldPartialBonus;
}
return 0;
}
/// Score podcast-specific fields (creator, description).
double _scorePodcastFields(MediaItem item, NormalizedQuery nq) {
double bonus = 0;
final metadata = item.metadata;
if (metadata != null) {
// Check creator fields
final creatorFields = [
metadata['author'] as String?,
metadata['publisher'] as String?,
metadata['owner'] as String?,
metadata['creator'] as String?,
].where((s) => s != null && s.isNotEmpty).map((s) => s!.toLowerCase());
bool foundExact = false;
bool foundContains = false;
for (final field in creatorFields) {
if (field == nq.withoutStopwords) {
foundExact = true;
break;
} else if (field.contains(nq.withoutStopwords)) {
foundContains = true;
}
}
if (foundExact) {
bonus += _config.creatorFieldExactBonus;
} else if (foundContains) {
bonus += _config.creatorFieldPartialBonus;
}
// Check description
final description =
(metadata['description'] as String? ?? '').toLowerCase();
if (description.contains(nq.withoutStopwords)) {
bonus += _config.descriptionBonus;
}
}
// Fallback: podcast name prominence check
if (bonus == 0) {
final nameLower = item.name.toLowerCase();
if (nameLower.contains(nq.withoutStopwords)) {
if (nq.withoutStopwords.contains(' ')) {
// Multi-word query prominence
final prominence = nq.withoutStopwords.length / nameLower.length;
if (prominence >= 0.5) {
bonus += _config.creatorFieldExactBonus;
} else if (prominence >= 0.3) {
bonus += _config.creatorFieldPartialBonus + 4;
} else {
bonus += _config.creatorFieldPartialBonus;
}
} else {
bonus += _config.descriptionBonus;
}
}
}
return bonus;
}
/// Calculate library and favorite bonuses.
double _calculateBonuses(MediaItem item) {
double bonus = 0;
// Library bonus (Album has inLibrary property)
if (item is Album && item.inLibrary) {
bonus += _config.libraryBonus;
}
// Favorite bonus
if (item.favorite == true) {
bonus += _config.favoriteBonus;
}
return bonus;
}
}
+114
View File
@@ -0,0 +1,114 @@
/// Text normalization utilities for search scoring.
///
/// Handles query normalization, stopword removal, and tokenization.
/// Represents a normalized search query with various forms for matching.
class NormalizedQuery {
/// Original query as entered by user
final String original;
/// Lowercased and trimmed query
final String normalized;
/// Query with stopwords removed
final String withoutStopwords;
/// Individual tokens from normalized query
final List<String> tokens;
/// Tokens with stopwords removed
final List<String> tokensNoStop;
const NormalizedQuery({
required this.original,
required this.normalized,
required this.withoutStopwords,
required this.tokens,
required this.tokensNoStop,
});
/// Whether the query is empty after normalization
bool get isEmpty => normalized.isEmpty;
/// Whether stopword removal changed the query
bool get hasStopwordsRemoved => normalized != withoutStopwords;
}
/// Normalizes text and queries for search matching.
class TextNormalizer {
/// Common English stopwords relevant to music/media searches.
///
/// Intentionally minimal to avoid over-filtering:
/// - Articles that commonly prefix artist/album names
/// - Common prepositions that add noise
static const Set<String> _stopwords = {
// Articles
'the',
'a',
'an',
// Common prepositions in music/media context
'of',
'and',
'in',
'on',
'at',
'to',
'for',
'with',
'by',
// Common noise words
'is',
'are',
'was',
'be',
};
/// Normalize a search query into multiple forms for matching.
///
/// Returns a [NormalizedQuery] containing:
/// - Original query
/// - Lowercased/trimmed version
/// - Version with stopwords removed
/// - Tokenized versions
NormalizedQuery normalizeQuery(String query) {
final normalized = query.toLowerCase().trim();
final tokens = tokenize(normalized);
final tokensNoStop = tokens.where((t) => !_stopwords.contains(t)).toList();
// If all tokens were stopwords, preserve original tokens
// e.g., "The The" (band name) shouldn't become empty
final effectiveTokensNoStop =
tokensNoStop.isEmpty ? tokens : tokensNoStop;
final withoutStopwords = effectiveTokensNoStop.join(' ');
return NormalizedQuery(
original: query,
normalized: normalized,
withoutStopwords: withoutStopwords,
tokens: tokens,
tokensNoStop: effectiveTokensNoStop,
);
}
/// Normalize text for comparison (lowercase, trim).
String normalizeText(String text) {
return text.toLowerCase().trim();
}
/// Normalize text with stopwords removed.
String normalizeTextNoStopwords(String text) {
final tokens = tokenize(text.toLowerCase().trim());
final filtered = tokens.where((t) => !_stopwords.contains(t)).toList();
return filtered.isEmpty ? text.toLowerCase().trim() : filtered.join(' ');
}
/// Split text into tokens on whitespace.
List<String> tokenize(String text) {
return text.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
}
/// Check if a word is a stopword.
bool isStopword(String word) {
return _stopwords.contains(word.toLowerCase());
}
}
+46 -5
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -5,6 +6,7 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/album_details_screen.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
@@ -13,12 +15,16 @@ class AlbumCard extends StatefulWidget {
final Album album;
final VoidCallback? onTap;
final String? heroTagSuffix;
/// Image decode size in pixels. Defaults to 256.
/// Use smaller values (e.g., 128) for list views, larger for grids.
final int? imageCacheSize;
const AlbumCard({
super.key,
required this.album,
this.onTap,
this.heroTagSuffix,
this.imageCacheSize,
});
@override
@@ -30,6 +36,11 @@ class _AlbumCardState extends State<AlbumCard> {
bool _triedFallback = false;
bool _maImageFailed = false;
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
bool _isNavigating = false;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@override
void initState() {
@@ -37,6 +48,12 @@ class _AlbumCardState extends State<AlbumCard> {
_initFallbackImage();
}
@override
void dispose() {
_fallbackTimer?.cancel();
super.dispose();
}
void _initFallbackImage() {
// Check if MA has an image after first build, then fetch fallback if needed
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -47,6 +64,16 @@ class _AlbumCardState extends State<AlbumCard> {
if (maImageUrl == null && !_triedFallback) {
_triedFallback = true;
_scheduleFallbackFetch();
}
});
}
/// Schedule fallback fetch with delay to avoid requests during fast scroll
void _scheduleFallbackFetch() {
_fallbackTimer?.cancel();
_fallbackTimer = Timer(_fallbackDelay, () {
if (mounted) {
_fetchFallbackImage();
}
});
@@ -69,7 +96,7 @@ class _AlbumCardState extends State<AlbumCard> {
if (!_triedFallback && !_maImageFailed) {
_maImageFailed = true;
_triedFallback = true;
_fetchFallbackImage();
_scheduleFallbackFetch();
}
}
@@ -86,9 +113,16 @@ class _AlbumCardState extends State<AlbumCard> {
// Use fallback if MA image failed or wasn't available
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
// PERF: Use appropriate cache size based on display size
final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
// Prevent double-tap navigation
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
@@ -97,9 +131,15 @@ class _AlbumCardState extends State<AlbumCard> {
child: AlbumDetailsScreen(
album: widget.album,
heroTagSuffix: widget.heroTagSuffix,
initialImageUrl: imageUrl,
),
),
);
).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -117,9 +157,10 @@ class _AlbumCardState extends State<AlbumCard> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
+110 -69
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -10,6 +11,8 @@ class AlbumRow extends StatefulWidget {
final Future<List<Album>> Function() loadAlbums;
final String? heroTagSuffix;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Album>? Function()? getCachedAlbums;
const AlbumRow({
super.key,
@@ -17,6 +20,7 @@ class AlbumRow extends StatefulWidget {
required this.loadAlbums,
this.heroTagSuffix,
this.rowHeight,
this.getCachedAlbums,
});
@override
@@ -24,8 +28,8 @@ class AlbumRow extends StatefulWidget {
}
class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin {
late Future<List<Album>> _albumsFuture;
List<Album>? _cachedAlbums; // Keep last loaded data to prevent flash
List<Album> _albums = [];
bool _isLoading = true;
bool _hasLoaded = false;
@override
@@ -34,21 +38,116 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
@override
void initState() {
super.initState();
_loadAlbumsOnce();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedAlbums?.call();
if (cached != null && cached.isNotEmpty) {
_albums = cached;
_isLoading = false;
}
_loadAlbums();
}
void _loadAlbumsOnce() {
if (!_hasLoaded) {
_albumsFuture = widget.loadAlbums().then((albums) {
_cachedAlbums = albums;
return albums;
});
_hasLoaded = true;
Future<void> _loadAlbums() async {
if (_hasLoaded) return;
_hasLoaded = true;
// Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshAlbums = await widget.loadAlbums();
if (mounted && freshAlbums.isNotEmpty) {
setState(() {
_albums = freshAlbums;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheAlbumImages(freshAlbums);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
/// Pre-cache album images so hero animations are smooth on first tap
void _precacheAlbumImages(List<Album> albums) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
// Only precache first ~10 visible items to avoid excessive network/memory use
final albumsToCache = albums.take(10);
for (final album in albumsToCache) {
final imageUrl = maProvider.api?.getImageUrl(album, size: 256);
if (imageUrl != null) {
// Use CachedNetworkImageProvider to warm the cache
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_albums.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_albums.isEmpty) {
return Center(
child: Text(
'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _albums.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = _albums[index];
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('AlbumRow:${widget.title}');
@@ -78,65 +177,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
),
),
Expanded(
child: FutureBuilder<List<Album>>(
future: _albumsFuture,
builder: (context, snapshot) {
// Use cached data immediately if available (prevents flash on rebuild)
final albums = snapshot.data ?? _cachedAlbums;
// Only show loading if we have no data at all
if (albums == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && albums == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (albums == null || albums.isEmpty) {
return Center(
child: Text(
'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: albums.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
},
),
child: _buildContent(contentHeight, colorScheme),
),
],
),

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