Compare commits

..
167 Commits
Author SHA1 Message Date
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
60 changed files with 8532 additions and 1807 deletions
+16 -1
View File
@@ -35,6 +35,16 @@ jobs:
channel: 'stable'
cache: true
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Get dependencies
run: flutter pub get
@@ -65,7 +75,12 @@ jobs:
EOF
- name: Build APK
run: flutter build apk --release
run: |
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
mkdir -p ~/.gradle
cp android/init.gradle ~/.gradle/init.gradle
flutter build apk --release
- name: Upload APK artifact
uses: actions/upload-artifact@v4
+182
View File
@@ -0,0 +1,182 @@
# Player System Animation & Polish Audit
## Context
This is a Music Assistant client app (Ensemble) built with Flutter. The player system has undergone extensive development and now needs a comprehensive polish audit. The goal is buttery smooth 60fps animations and a premium user experience.
## Architecture Overview
The player system consists of three interconnected components:
1. **Mini Player** (collapsed state) - Floating card at bottom, shows current track
2. **Expanded Player** - Full-screen player with album art, controls, progress
3. **Queue Panel** - Slides in from right of expanded player, shows playback queue
Additionally:
- **Player Selector** - Horizontal swipe between multiple players (collapsed mode)
- **Device Reveal** - Swipe down from mini player to show available players
## Key Files
```
lib/widgets/
├── expandable_player.dart # Main player widget (1800+ lines)
├── global_player_overlay.dart # Overlay wrapper, static accessors
├── player/
│ ├── queue_panel.dart # Queue list with great_list_view
│ ├── chapters_panel.dart # Audiobook chapters
│ └── mini_player_content.dart
```
## CRITICAL CONSTRAINTS
1. **DO NOT MODIFY the queue list implementation** - It uses `great_list_view` package with `AutomaticAnimatedListView`. This was chosen specifically to avoid Flutter's grey screen overlay bug (#103804). The current implementation works correctly.
2. **Preserve all existing functionality** - Focus on polish, not rewrites
3. **Test changes incrementally** - This system is complex with many interdependencies
## Audit Areas
### 1. Animation Performance Audit
Use sub-agents to analyze each animation system:
**Agent 1: Expand/Collapse Animation**
- Analyze `_controller` and `_expandAnimation`
- Check for jank during morphing transition
- Verify lerp functions are efficient
- Look for unnecessary rebuilds during animation
- Check `RepaintBoundary` placement
**Agent 2: Queue Panel Animation**
- Analyze `_queuePanelController` and `_queuePanelAnimation`
- Verify slide-in/out is smooth
- Check interaction with expanded player state
- Verify the Listener-based swipe detection doesn't cause issues
**Agent 3: Player Selector Animation**
- Analyze horizontal swipe between players
- Check `_slideOffset` and peek player animations
- Verify finger-following feels natural
- Look for edge cases with rapid swipes
**Agent 4: Micro-interactions**
- Play/pause button animations
- Progress bar interactions
- Volume swipe overlay
- Favorite button feedback
- Skip button responsiveness
### 2. State Management Audit
**Check for:**
- Unnecessary `setState` calls during animations
- ValueNotifier usage efficiency
- Animation listener cleanup
- Memory leaks from subscriptions
- State synchronization between components
### 3. Visual Polish Audit
**Check for:**
- Color transitions during expand/collapse
- Text fade/scale animations
- Image loading placeholders
- Shadow/elevation consistency
- Border radius morphing smoothness
- Safe area handling
### 4. Gesture Conflict Resolution
**Verify:**
- Vertical drag (expand/collapse) vs horizontal drag (player switch)
- Queue panel swipe-right-to-close vs Dismissible swipe-left-to-delete
- Edge dead zones for Android back gesture
- Tap vs drag disambiguation
### 5. Edge Cases
**Test scenarios:**
- Rapid expand/collapse
- Swipe during animation
- Back button during animations
- Track change during queue scroll
- Network image loading failures
- Very long track/artist names
## Execution Strategy
```
ultrathink: true
```
### Phase 1: Discovery (Read-Only)
Launch parallel sub-agents to analyze each file:
- Read and understand the animation architecture
- Map all animation controllers and their relationships
- Identify potential performance bottlenecks
- Document current RepaintBoundary usage
### Phase 2: Analysis Report
Compile findings into categories:
- **Critical**: Causes visible jank or bugs
- **Important**: Noticeable but not breaking
- **Nice-to-have**: Minor polish improvements
### Phase 3: Implementation Plan
For each finding:
- Specific file and line numbers
- Proposed change
- Risk assessment
- Testing approach
### Phase 4: Incremental Fixes
- One fix at a time
- Build and test after each change
- Verify no regressions
## Output Format
Provide a structured audit report:
```markdown
## Animation Audit Report
### Executive Summary
[2-3 sentences on overall state]
### Critical Issues
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
### Important Issues
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
### Polish Opportunities
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
### Architecture Observations
[Notes on overall structure, patterns noticed]
### Recommended Priority Order
1. [First fix]
2. [Second fix]
...
```
## Success Criteria
After this audit and fixes:
- All animations run at consistent 60fps
- No jank during any transition
- Gestures feel responsive and natural
- State transitions are seamless
- No visual glitches or flickers
- Memory usage is stable during interactions
## Notes
- The app uses adaptive theming (colors extracted from album art)
- There's an `AnimationDebugger` utility for profiling
- Position tracking uses a shared `PositionTracker` singleton
- The player supports both music and audiobooks (different UI modes)
+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)
+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()
}
}
+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

+15
View File
@@ -28,4 +28,19 @@ class HeroTags {
/// Podcast cover hero tag prefix
static const String podcastCover = 'podcast_cover_';
/// Podcast title hero tag prefix
static const String podcastTitle = 'podcast_title_';
/// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_';
/// Playlist title hero tag prefix
static const String playlistTitle = 'playlist_title_';
/// Radio station cover hero tag prefix
static const String radioCover = 'radio_cover_';
/// Radio station title hero tag prefix
static const String radioTitle = 'radio_title_';
}
+1
View File
@@ -203,6 +203,7 @@
"english": "Englisch",
"german": "Deutsch",
"spanish": "Spanisch",
"french": "Französisch",
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
"sortAlphabetically": "Alphabetisch sortieren",
+23
View File
@@ -251,6 +251,21 @@
"description": "Home screen section title"
},
"favoritePlaylists": "Favorite Playlists",
"@favoritePlaylists": {
"description": "Home screen section title"
},
"favoriteRadioStations": "Favorite Radio Stations",
"@favoriteRadioStations": {
"description": "Home screen section title"
},
"favoritePodcasts": "Favorite Podcasts",
"@favoritePodcasts": {
"description": "Home screen section title"
},
"searchMusic": "Search music...",
"@searchMusic": {
"description": "Search placeholder text"
@@ -1045,6 +1060,11 @@
"description": "Spanish language"
},
"french": "French",
"@french": {
"description": "French language"
},
"noTracksInPlaylist": "No tracks in playlist",
"sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year",
@@ -1062,6 +1082,9 @@
"showFavoriteAlbums": "Show a row of your favorite albums",
"showFavoriteArtists": "Show a row of your favorite artists",
"showFavoriteTracks": "Show a row of your favorite tracks",
"showFavoritePlaylists": "Show a row of your favorite playlists",
"showFavoriteRadioStations": "Show a row of your favorite radio stations",
"showFavoritePodcasts": "Show a row of your favorite podcasts",
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
"addedToFavorites": "Added to favorites",
+1
View File
@@ -360,6 +360,7 @@
"english": "Inglés",
"german": "Alemán",
"spanish": "Español",
"french": "Francés",
"noTracksInPlaylist": "No hay canciones en la lista",
"sortAlphabetically": "Ordenar alfabéticamente",
"sortByYear": "Ordenar por año",
+1400
View File
File diff suppressed because it is too large Load Diff
+52 -11
View File
@@ -71,13 +71,16 @@ 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,
),
);
@@ -176,7 +179,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
@override
Future<bool> didPopRoute() async {
// Intercept back button at app level - runs BEFORE Navigator processes it
// Priority order: device list > expanded player > normal navigation
// Priority order: device list > queue panel > expanded player > normal navigation
// Device list has highest priority - dismiss it first
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
@@ -184,6 +187,14 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
return true; // We handled it, don't let Navigator process it
}
// Queue panel second - close it before collapsing player
// Use target state (not animation value) to handle rapid open-close timing
// Use withHaptic: false because Android back gesture provides system haptic
if (GlobalPlayerOverlay.isQueuePanelTargetOpen) {
GlobalPlayerOverlay.closeQueuePanel(withHaptic: false);
return true; // We handled it, don't let Navigator process it
}
// Then check expanded player
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
@@ -229,10 +240,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
ColorScheme? darkColorScheme;
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
// Use system color schemes
// Use system color schemes, but override background to match app design
// Material You doesn't set background consistently, causing black screen issues
final (light, dark) = snapshot.data!;
lightColorScheme = light;
darkColorScheme = dark;
lightColorScheme = light.copyWith(
surface: light.surface,
background: const Color(0xFFFAFAFA), // App's preferred light background
);
darkColorScheme = dark.copyWith(
surface: const Color(0xFF2a2a2a), // App's preferred dark surface
background: const Color(0xFF1a1a1a), // App's preferred dark background
);
} else {
// Use custom color from theme provider
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
@@ -256,6 +274,29 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
],
supportedLocales: S.supportedLocales,
locale: localeProvider.locale,
// Fallback to English when locale is not supported
localeListResolutionCallback: (locales, supportedLocales) {
// If user has set a specific locale, try to use it
if (localeProvider.locale != null) {
for (final supported in supportedLocales) {
if (supported.languageCode == localeProvider.locale!.languageCode) {
return supported;
}
}
}
// Try to match system locales
if (locales != null) {
for (final locale in locales) {
for (final supported in supportedLocales) {
if (supported.languageCode == locale.languageCode) {
return supported;
}
}
}
}
// Fallback to English (not German)
return const Locale('en');
},
themeMode: themeProvider.themeMode,
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
@@ -332,8 +373,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,
),
);
+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:
+182 -41
View File
@@ -65,6 +65,7 @@ class MusicAssistantProvider with ChangeNotifier {
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
Track? _currentTrack;
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
String? _currentPodcastName; // Currently playing podcast's name (set when playing episode)
Timer? _playerStateTimer;
Timer? _notificationPositionTimer; // Updates notification position every second for remote players
@@ -333,14 +334,18 @@ class MusicAssistantProvider with ChangeNotifier {
}
/// Get the podcast name when playing a podcast episode
/// Returns the podcast name from metadata, album name, or artist name
/// Returns the podcast name from stored context, metadata, or fallbacks
String? get currentPodcastName {
if (!isPlayingPodcast || _currentTrack == null) return null;
// Try metadata first (if episode has parent podcast info)
// Primary source: stored podcast name from when episode was played
if (_currentPodcastName != null && _currentPodcastName!.isNotEmpty) {
return _currentPodcastName;
}
// Try metadata (if episode has parent podcast info from API)
final metadata = _currentTrack!.metadata;
if (metadata != null) {
// Check for podcast_name or podcast.name in metadata
if (metadata['podcast_name'] != null) {
return metadata['podcast_name'] as String;
}
@@ -351,20 +356,26 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
}
// Fall back to album name (often contains podcast name)
if (_currentTrack!.album != null && _currentTrack!.album!.name.isNotEmpty) {
return _currentTrack!.album!.name;
}
// Fall back to artist (sometimes the podcast name is set as artist)
if (_currentTrack!.artists != null && _currentTrack!.artists!.isNotEmpty) {
final artistName = _currentTrack!.artists!.first.name;
if (artistName != 'Unknown Artist' && artistName.isNotEmpty) {
return artistName;
}
}
// Fallbacks removed - album/artist contain episode name, not podcast name
// Return null to let UI show generic "Podcasts" label
return null;
}
/// Set the current podcast context (call when playing a podcast episode)
void setCurrentPodcastName(String? podcastName) {
_currentPodcastName = podcastName;
_logger.log('🎙️ Set current podcast name: $podcastName');
}
/// Clear the podcast context
void clearCurrentPodcastName() {
if (_currentPodcastName != null) {
_logger.log('🎙️ Cleared podcast context');
_currentPodcastName = null;
}
}
String get lastSearchQuery => _lastSearchQuery;
Map<String, List<MediaItem>> get lastSearchResults => filterSearchResults(_lastSearchResults);
@@ -2350,6 +2361,12 @@ class MusicAssistantProvider with ChangeNotifier {
clearCurrentAudiobook();
}
// Clear podcast context when switching to non-podcast media
if (mediaType != 'podcast_episode' && _currentPodcastName != null) {
_logger.log('🎙️ Media type changed to $mediaType - clearing podcast context');
clearCurrentPodcastName();
}
if (mediaType != 'flow_stream') {
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
final albumName = currentMedia['album'] as String?;
@@ -2416,17 +2433,22 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('📻 Found artist from artists array: $artistName');
}
// Check for stream_title which might contain "Artist - Title" format
// Check for stream_title which contains the actual now-playing metadata
// For radio, stream_title has "Artist - Title" format from the stream's ICY metadata
final streamTitle = currentMedia['stream_title'] as String?;
if (streamTitle != null && streamTitle.contains(' - ') && artistName == null) {
if (streamTitle != null && streamTitle.isNotEmpty) {
if (streamTitle.contains(' - ')) {
// Parse "Artist - Title" format
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artistName = parts[0].trim();
// If trackTitle is still the default or matches the stream_title, update it too
if (trackTitle == 'Unknown Track' || trackTitle == streamTitle) {
trackTitle = parts.sublist(1).join(' - ').trim();
_logger.log('📻 Parsed from stream_title: artist=$artistName, title=$trackTitle');
}
_logger.log('📻 Parsed artist from stream_title: $artistName, title: $trackTitle');
} else {
// No separator, use stream_title as the title
trackTitle = streamTitle;
_logger.log('📻 Using stream_title as title: $trackTitle');
}
}
}
@@ -2453,14 +2475,19 @@ class MusicAssistantProvider with ChangeNotifier {
// Only cache if we don't already have better data from queue
final existingTrack = _cacheService.getCachedTrackForPlayer(playerId);
// Check if existing track has proper artist (not Unknown Artist and not empty)
final existingArtist = existingTrack?.artistsString;
final existingHasProperArtist = existingTrack != null &&
existingTrack.artistsString != 'Unknown Artist' &&
!existingTrack.name.contains(' - ');
existingArtist != null &&
existingArtist != 'Unknown Artist' &&
existingArtist.trim().isNotEmpty;
final existingHasImage = existingTrack?.metadata?['images'] != null;
final newHasImage = metadata != null;
final newHasAlbum = albumName != null;
final existingHasAlbum = existingTrack?.album != null;
final newHasProperArtist = artistName != 'Unknown Artist';
// Check if new data has proper artist AND title (not malformed like "- Something")
final newTitleIsMalformed = trackTitle.startsWith('- ') || trackTitle.trim().isEmpty;
final newHasProperArtist = artistName != 'Unknown Artist' && !newTitleIsMalformed;
// For podcasts, album info is crucial (it's the podcast name)
// If new track has album but existing doesn't, prefer new or merge
@@ -2511,13 +2538,20 @@ class MusicAssistantProvider with ChangeNotifier {
}
// For selected player, _updatePlayerState() is already called above which fetches queue data
// Only update _currentTrack here if we don't have it yet OR if we have better album data for podcasts
// Update _currentTrack if:
// - We don't have it yet
// - Podcast with new album data
// - Radio with new stream metadata (artist changed from Unknown)
final currentHasUnknownArtist = _currentTrack?.artistsString == 'Unknown Artist' ||
_currentTrack?.artistsString == null;
final shouldUpdateCurrentTrack = _selectedPlayer != null &&
playerId == _selectedPlayer!.playerId &&
(_currentTrack == null || (isPodcastUri && newHasAlbum && _currentTrack?.album == null));
(_currentTrack == null ||
(isPodcastUri && newHasAlbum && _currentTrack?.album == null) ||
(isRadioUri && newHasProperArtist && currentHasUnknownArtist));
if (shouldUpdateCurrentTrack) {
_currentTrack = _cacheService.getCachedTrackForPlayer(playerId) ?? trackFromEvent;
_logger.log('📋 Updated _currentTrack for podcast with album: ${_currentTrack?.album?.name}');
_logger.log('📋 Updated _currentTrack: ${_currentTrack?.name} by ${_currentTrack?.artistsString}');
}
notifyListeners();
@@ -2554,15 +2588,19 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
// Check for stream_title with "Artist - Title" format
// Check for stream_title which contains the actual now-playing metadata
final streamTitle = currentMedia['stream_title'] as String?;
if (streamTitle != null && streamTitle.contains(' - ') && artist == null) {
if (streamTitle != null && streamTitle.isNotEmpty) {
if (streamTitle.contains(' - ')) {
// Parse "Artist - Title" format
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artist = parts[0].trim();
if (title == 'Unknown Track' || title == streamTitle) {
title = parts.sublist(1).join(' - ').trim();
}
} else {
// No separator, use stream_title as the title
title = streamTitle;
}
}
}
@@ -2778,13 +2816,59 @@ class MusicAssistantProvider with ChangeNotifier {
return _cachedFavoriteTracks;
}
/// Get favorite playlists from the library
Future<List<Playlist>> getFavoritePlaylists() async {
if (_api == null) return [];
try {
return await getPlaylists(favoriteOnly: true);
} catch (e) {
_logger.log('❌ Failed to fetch favorite playlists: $e');
return [];
}
}
/// Get favorite radio stations from the library
Future<List<MediaItem>> getFavoriteRadioStations() async {
if (_api == null) return [];
try {
final stations = await _api!.getRadioStations(favoriteOnly: true);
return filterByProvider(stations);
} catch (e) {
_logger.log('❌ Failed to fetch favorite radio stations: $e');
return [];
}
}
/// Get favorite podcasts from the library
Future<List<MediaItem>> getFavoritePodcasts() async {
if (_api == null) return [];
try {
final podcasts = await _api!.getPodcasts(favoriteOnly: true);
return filterByProvider(podcasts);
} catch (e) {
_logger.log('❌ Failed to fetch favorite podcasts: $e');
return [];
}
}
// ============================================================================
// AUDIOBOOK HOME SCREEN ROWS
// ============================================================================
/// Get audiobooks that have progress (continue listening)
Future<List<Audiobook>> getInProgressAudiobooks() async {
if (_api == null) return [];
/// Get audiobooks that have progress (continue listening) with caching
Future<List<Audiobook>> getInProgressAudiobooksWithCache({bool forceRefresh = false}) async {
// Check cache first
if (!forceRefresh && _cacheService.isInProgressAudiobooksCacheValid()) {
_logger.log('📦 Using cached in-progress audiobooks');
return _cacheService.getCachedInProgressAudiobooks()!;
}
if (_api == null) {
// Fallback to cache when offline
final cached = _cacheService.getCachedInProgressAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
try {
_logger.log('📚 Fetching in-progress audiobooks...');
@@ -2794,48 +2878,94 @@ class MusicAssistantProvider with ChangeNotifier {
.where((a) => a.progress > 0 && a.progress < 1.0) // Has progress but not complete
.toList()
..sort((a, b) => b.progress.compareTo(a.progress)); // Sort by progress descending
_logger.log('📚 Found ${inProgress.length} in-progress audiobooks');
return inProgress.take(20).toList(); // Limit to 20 for home row
final result = inProgress.take(20).toList(); // Limit to 20 for home row
_logger.log('📚 Found ${result.length} in-progress audiobooks');
_cacheService.setCachedInProgressAudiobooks(result);
return result;
} catch (e) {
_logger.log('❌ Failed to fetch in-progress audiobooks: $e');
// Fallback to cache on error
final cached = _cacheService.getCachedInProgressAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
}
/// Get random audiobooks for discovery
Future<List<Audiobook>> getDiscoverAudiobooks() async {
if (_api == null) return [];
/// Get cached in-progress audiobooks synchronously (for instant display)
List<Audiobook>? getCachedInProgressAudiobooks() => _cacheService.getCachedInProgressAudiobooks();
/// Get random audiobooks for discovery with caching
Future<List<Audiobook>> getDiscoverAudiobooksWithCache({bool forceRefresh = false}) async {
// Check cache first
if (!forceRefresh && _cacheService.isDiscoverAudiobooksCacheValid()) {
_logger.log('📦 Using cached discover audiobooks');
return _cacheService.getCachedDiscoverAudiobooks()!;
}
if (_api == null) {
// Fallback to cache when offline
final cached = _cacheService.getCachedDiscoverAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
try {
_logger.log('📚 Fetching discover audiobooks...');
final allAudiobooks = filterByProvider(await _api!.getAudiobooks());
// Shuffle and take a subset
final shuffled = List<Audiobook>.from(allAudiobooks)..shuffle();
final result = shuffled.take(20).toList();
_logger.log('📚 Found ${allAudiobooks.length} total audiobooks, returning random selection');
return shuffled.take(20).toList();
_cacheService.setCachedDiscoverAudiobooks(result);
return result;
} catch (e) {
_logger.log('❌ Failed to fetch discover audiobooks: $e');
// Fallback to cache on error
final cached = _cacheService.getCachedDiscoverAudiobooks();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
}
/// Get random series for discovery
Future<List<AudiobookSeries>> getDiscoverSeries() async {
if (_api == null) return [];
/// Get cached discover audiobooks synchronously (for instant display)
List<Audiobook>? getCachedDiscoverAudiobooks() => _cacheService.getCachedDiscoverAudiobooks();
/// Get random series for discovery with caching
Future<List<AudiobookSeries>> getDiscoverSeriesWithCache({bool forceRefresh = false}) async {
// Check cache first
if (!forceRefresh && _cacheService.isDiscoverSeriesCacheValid()) {
_logger.log('📦 Using cached discover series');
return _cacheService.getCachedDiscoverSeries()!;
}
if (_api == null) {
// Fallback to cache when offline
final cached = _cacheService.getCachedDiscoverSeries();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
try {
_logger.log('📚 Fetching discover series...');
final allSeries = await _api!.getAudiobookSeries();
// Shuffle and take a subset
final shuffled = List<AudiobookSeries>.from(allSeries)..shuffle();
final result = shuffled.take(20).toList();
_logger.log('📚 Found ${allSeries.length} total series, returning random selection');
return shuffled.take(20).toList();
_cacheService.setCachedDiscoverSeries(result);
return result;
} catch (e) {
_logger.log('❌ Failed to fetch discover series: $e');
// Fallback to cache on error
final cached = _cacheService.getCachedDiscoverSeries();
if (cached != null && cached.isNotEmpty) return cached;
return [];
}
}
/// Get cached discover series synchronously (for instant display)
List<AudiobookSeries>? getCachedDiscoverSeries() => _cacheService.getCachedDiscoverSeries();
// ============================================================================
// DETAIL SCREEN CACHING
// ============================================================================
@@ -3946,6 +4076,11 @@ class MusicAssistantProvider with ChangeNotifier {
_currentAudiobook = null;
stateChanged = true;
}
if (_currentPodcastName != null) {
_logger.log('🎙️ External source active, clearing podcast context');
_currentPodcastName = null;
stateChanged = true;
}
// Clear notification for external source
audioHandler.clearRemotePlaybackState();
if (stateChanged) {
@@ -3987,6 +4122,12 @@ class MusicAssistantProvider with ChangeNotifier {
_currentAudiobook = null;
stateChanged = true;
}
// Clear podcast context when playback stops
if (_currentPodcastName != null) {
_logger.log('🎙️ Playback stopped, clearing podcast context');
_currentPodcastName = null;
stateChanged = true;
}
if (stateChanged) {
notifyListeners();
+6
View File
@@ -68,6 +68,12 @@ class _HomeScreenState extends State<HomeScreen> {
return;
}
// If queue panel is open, close it first (before collapsing player)
if (GlobalPlayerOverlay.isQueuePanelOpen) {
GlobalPlayerOverlay.closeQueuePanel();
return;
}
// If global player is expanded, collapse it first
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
+41 -16
View File
@@ -4,6 +4,9 @@ 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';
@@ -104,27 +107,46 @@ 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(
leading: Hero(
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
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,
),
)
: null,
: Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
child: imageUrl == null
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
),
),
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,
@@ -133,6 +155,8 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
subtitle: Text(
playlist.trackCount != null
? '${playlist.trackCount} ${S.of(context)!.tracks}'
@@ -147,13 +171,14 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
? const Icon(Icons.favorite, color: Colors.red, size: 20)
: null,
onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistDetailsScreen(
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
provider: playlist.provider,
itemId: playlist.itemId,
heroTagSuffix: 'playlists',
initialImageUrl: imageUrl,
),
),
);
+157 -6
View File
@@ -13,6 +13,9 @@ import '../widgets/artist_row.dart';
import '../widgets/track_row.dart';
import '../widgets/audiobook_row.dart';
import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart';
import '../widgets/podcast_row.dart';
import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -35,6 +38,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
bool _showFavoritePodcasts = false;
// Audiobook rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
@@ -45,19 +51,89 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
@override
bool get wantKeepAlive => true;
// Track if we had empty favorites on first load (to know when to refresh)
bool _hadEmptyFavoritesOnLoad = false;
SyncStatus? _lastSyncStatus;
int _lastArtistCount = 0;
MusicAssistantProvider? _providerListeningTo;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadSettings();
// Listen to sync completion to refresh favorite rows when data becomes available
SyncService.instance.addListener(_onSyncChanged);
_lastSyncStatus = SyncService.instance.status;
_checkInitialFavoriteState();
}
@override
void dispose() {
SyncService.instance.removeListener(_onSyncChanged);
// Clean up provider listener if attached
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
/// Check if favorites are empty on initial load (so we know to refresh after sync)
void _checkInitialFavoriteState() {
// Schedule after first frame to ensure provider is accessible
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
_lastArtistCount = provider.artists.length;
// Check if artists list is empty - favorites will also be empty
if (_lastArtistCount == 0) {
_hadEmptyFavoritesOnLoad = true;
_logger.log('📋 Home: favorites empty on load, will refresh after sync');
// Also listen to provider for when cache loads (before sync completes)
_providerListeningTo = provider;
provider.addListener(_onProviderChanged);
}
});
}
/// Called when MusicAssistantProvider changes (for cache load detection)
void _onProviderChanged() {
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
final currentCount = _providerListeningTo!.artists.length;
// If artists went from 0 to non-zero, refresh
if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false;
_providerListeningTo!.removeListener(_onProviderChanged);
_providerListeningTo = null;
setState(() {
_refreshKey = UniqueKey();
});
}
_lastArtistCount = currentCount;
}
/// Called when SyncService status changes
void _onSyncChanged() {
final newStatus = SyncService.instance.status;
// When sync completes and we had empty favorites, refresh to show new data
if (_lastSyncStatus == SyncStatus.syncing &&
newStatus == SyncStatus.completed &&
_hadEmptyFavoritesOnLoad) {
_logger.log('🔄 Home: sync completed, refreshing favorite rows');
_hadEmptyFavoritesOnLoad = false;
// Clean up provider listener if still attached
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
if (mounted) {
setState(() {
_refreshKey = UniqueKey();
});
}
}
_lastSyncStatus = newStatus;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Reload settings when app resumes (coming back from settings)
@@ -73,6 +149,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
@@ -85,6 +164,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
@@ -238,15 +320,50 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
}
/// Count how many rows are currently enabled
int _countEnabledRows() {
int count = 0;
for (final rowId in _homeRowOrder) {
if (_isRowEnabled(rowId)) count++;
}
return count;
}
/// Check if a specific row is enabled
bool _isRowEnabled(String rowId) {
switch (rowId) {
case 'recent-albums': return _showRecentAlbums;
case 'discover-artists': return _showDiscoverArtists;
case 'discover-albums': return _showDiscoverAlbums;
case 'continue-listening': return _showContinueListeningAudiobooks;
case 'discover-audiobooks': return _showDiscoverAudiobooks;
case 'discover-series': return _showDiscoverSeries;
case 'favorite-albums': return _showFavoriteAlbums;
case 'favorite-artists': return _showFavoriteArtists;
case 'favorite-tracks': return _showFavoriteTracks;
case 'favorite-playlists': return _showFavoritePlaylists;
case 'favorite-radio-stations': return _showFavoriteRadioStations;
case 'favorite-podcasts': return _showFavoritePodcasts;
default: return false;
}
}
Widget _buildConnectedView(
BuildContext context, MusicAssistantProvider provider) {
// Use LayoutBuilder to get available screen height
return LayoutBuilder(
builder: (context, constraints) {
// Simple calculation: available height divided by 3 rows
// Each row includes its title, artwork, and info
// Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
final rowHeight = (availableHeight + 8) / 3;
// Account for margins between rows (2px each)
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
// For 4+ rows, use original calculation so 4th row stays hidden (scrollable)
const marginSize = 2.0;
final enabledRows = _countEnabledRows();
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
final rowHeight = (availableHeight - marginsInView) / 3;
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
@@ -288,6 +405,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) {
// Add spacing between rows (not before first row)
if (rows.isNotEmpty) {
rows.add(const SizedBox(height: 2.0));
}
rows.add(widget);
}
}
@@ -330,7 +451,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
return AudiobookRow(
key: const ValueKey('continue-listening'),
title: S.of(context)!.continueListening,
loadAudiobooks: () => provider.getInProgressAudiobooks(),
loadAudiobooks: () => provider.getInProgressAudiobooksWithCache(),
getCachedAudiobooks: () => provider.getCachedInProgressAudiobooks(),
rowHeight: rowHeight,
);
case 'discover-audiobooks':
@@ -338,7 +460,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
return AudiobookRow(
key: const ValueKey('discover-audiobooks'),
title: S.of(context)!.discoverAudiobooks,
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
loadAudiobooks: () => provider.getDiscoverAudiobooksWithCache(),
getCachedAudiobooks: () => provider.getCachedDiscoverAudiobooks(),
rowHeight: rowHeight,
);
case 'discover-series':
@@ -346,7 +469,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
return SeriesRow(
key: const ValueKey('discover-series'),
title: S.of(context)!.discoverSeries,
loadSeries: () => provider.getDiscoverSeries(),
loadSeries: () => provider.getDiscoverSeriesWithCache(),
getCachedSeries: () => provider.getCachedDiscoverSeries(),
rowHeight: rowHeight,
);
case 'favorite-albums':
@@ -373,6 +497,33 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
loadTracks: () => provider.getFavoriteTracks(),
rowHeight: rowHeight,
);
case 'favorite-playlists':
if (!_showFavoritePlaylists) return null;
return PlaylistRow(
key: const ValueKey('favorite-playlists'),
title: S.of(context)!.favoritePlaylists,
loadPlaylists: () => provider.getFavoritePlaylists(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
case 'favorite-radio-stations':
if (!_showFavoriteRadioStations) return null;
return RadioStationRow(
key: const ValueKey('favorite-radio-stations'),
title: S.of(context)!.favoriteRadioStations,
loadRadioStations: () => provider.getFavoriteRadioStations(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
case 'favorite-podcasts':
if (!_showFavoritePodcasts) return null;
return PodcastRow(
key: const ValueKey('favorite-podcasts'),
title: S.of(context)!.favoritePodcasts,
loadPodcasts: () => provider.getFavoritePodcasts(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
default:
return null;
}
+380 -58
View File
@@ -64,6 +64,14 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Media type selection (Music, Books, Podcasts)
LibraryMediaType _selectedMediaType = LibraryMediaType.music;
// Track overscroll for switching between media types
double _horizontalOverscroll = 0;
static const double _overscrollThreshold = 80; // Pixels to trigger switch
// Track horizontal drag for single-category types (where PageView doesn't overscroll)
double _horizontalDragStart = 0;
double _horizontalDragDelta = 0;
// View mode settings
String _artistsViewMode = 'list'; // 'grid2', 'grid3', 'list'
String _albumsViewMode = 'grid2'; // 'grid2', 'grid3', 'list'
@@ -477,6 +485,26 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
});
}
/// Get the color for a media type - muted colors based on primaryContainer tone
Color _getMediaTypeColor(ColorScheme colorScheme, LibraryMediaType type) {
final baseColor = colorScheme.primaryContainer;
final baseHsl = HSLColor.fromColor(baseColor);
double hueShift;
switch (type) {
case LibraryMediaType.music:
hueShift = 0; // Keep primary hue (purple-ish)
case LibraryMediaType.books:
hueShift = 35; // Shift toward orange
case LibraryMediaType.podcasts:
hueShift = 160; // Shift toward teal
case LibraryMediaType.radio:
hueShift = -30; // Shift toward pink
}
return baseHsl.withHue((baseHsl.hue + hueShift) % 360).toColor();
}
void _changeMediaType(LibraryMediaType type) {
_logger.log('📚 _changeMediaType called: $type (current: $_selectedMediaType)');
if (_selectedMediaType == type) {
@@ -523,6 +551,70 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
_tabIndexNotifier.value = index;
}
/// Handle horizontal overscroll to switch between media types
bool _handleHorizontalOverscroll(ScrollNotification notification) {
if (notification.metrics.axis != Axis.horizontal) {
return false;
}
if (notification is OverscrollNotification) {
_horizontalOverscroll += notification.overscroll;
// Check if we've overscrolled enough to switch
// Positive overscroll = swiping left at the end = go to NEXT type
// Negative overscroll = swiping right at the start = go to PREVIOUS type
if (_horizontalOverscroll > _overscrollThreshold) {
_horizontalOverscroll = 0;
_switchToNextMediaType();
return true;
} else if (_horizontalOverscroll < -_overscrollThreshold) {
_horizontalOverscroll = 0;
_switchToPreviousMediaType();
return true;
}
} else if (notification is ScrollEndNotification) {
// Reset overscroll accumulation when scroll ends
_horizontalOverscroll = 0;
}
return false;
}
void _switchToNextMediaType() {
final types = LibraryMediaType.values;
final currentIndex = types.indexOf(_selectedMediaType);
final nextIndex = (currentIndex + 1) % types.length;
_changeMediaType(types[nextIndex]);
}
void _switchToPreviousMediaType() {
final types = LibraryMediaType.values;
final currentIndex = types.indexOf(_selectedMediaType);
final prevIndex = (currentIndex - 1 + types.length) % types.length;
_changeMediaType(types[prevIndex]);
}
// Drag handlers for single-category types
void _onHorizontalDragStart(DragStartDetails details) {
_horizontalDragStart = details.globalPosition.dx;
_horizontalDragDelta = 0;
}
void _onHorizontalDragUpdate(DragUpdateDetails details) {
_horizontalDragDelta = details.globalPosition.dx - _horizontalDragStart;
}
void _onHorizontalDragEnd(DragEndDetails details) {
// Swipe right (positive delta) = go to previous type
// Swipe left (negative delta) = go to next type
if (_horizontalDragDelta > _overscrollThreshold) {
_switchToPreviousMediaType();
} else if (_horizontalDragDelta < -_overscrollThreshold) {
_switchToNextMediaType();
}
_horizontalDragDelta = 0;
}
/// Handle scroll notifications to hide/show filter bars
bool _handleScrollNotification(ScrollNotification notification) {
// Don't hide while dragging letter scrollbar
@@ -801,6 +893,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
_seriesCoversLoading.remove(seriesId);
});
// Precache images for smooth hero animations
_precacheSeriesCovers(covers);
// PERF: Queue color extraction with debounce to avoid blocking UI during scroll
_queueColorExtraction(seriesId, covers);
}
@@ -811,6 +906,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
}
/// Precache series cover images for smooth hero animations
void _precacheSeriesCovers(List<String> covers) {
if (!mounted) return;
for (final url in covers) {
precacheImage(
CachedNetworkImageProvider(url),
context,
).catchError((_) => false);
}
}
/// PERF: Queue color extraction requests - processed after scroll settles
void _queueColorExtraction(String seriesId, List<String> coverUrls) {
if (coverUrls.isEmpty) return;
@@ -975,9 +1081,24 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: Stack(
children: [
// Main scrollable content
NotificationListener<ScrollNotification>(
// For single-category types, wrap with GestureDetector for swipe detection
// (PageView with 1 item doesn't generate overscroll)
_tabCount == 1
? GestureDetector(
onHorizontalDragStart: _onHorizontalDragStart,
onHorizontalDragUpdate: _onHorizontalDragUpdate,
onHorizontalDragEnd: _onHorizontalDragEnd,
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: _buildTabAtIndex(context, l10n, 0),
),
)
: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
// PERF: Use PageView.builder to only build visible tabs
// Wrapped with horizontal overscroll detection for media type switching
child: NotificationListener<ScrollNotification>(
onNotification: _handleHorizontalOverscroll,
child: PageView.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
@@ -987,6 +1108,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
itemBuilder: (context, index) => _buildTabAtIndex(context, l10n, index),
),
),
),
// Fade gradient at top - content fades as it scrolls under filter bar
Positioned(
top: 0,
@@ -1097,35 +1219,130 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
}
// Justify chips across full width
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: LibraryMediaType.values.map((type) {
final types = LibraryMediaType.values;
// Calculate flex values based on label length
// Longer labels get more space to avoid clipping
// Base flex of 10 for icon + padding, plus label length
int getFlexForType(LibraryMediaType type) {
final label = getMediaTypeLabel(type);
// Base space for icon/padding + proportional text space
return 10 + label.length;
}
// Pre-calculate flex values and total
final flexValues = types.map((t) => getFlexForType(t)).toList();
final totalFlex = flexValues.reduce((a, b) => a + b);
final selectedIndex = types.indexOf(_selectedMediaType);
// Horizontal inset for the pill highlight (gap between adjacent tabs)
const double hInset = 2.0;
final isFirstTab = selectedIndex == 0;
final isLastTab = selectedIndex == types.length - 1;
// Animated sliding highlight with pill shape
return LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
// Calculate left position for a given tab index
double getLeftPosition(int index) {
double left = 0;
for (int i = 0; i < index; i++) {
left += (flexValues[i] / totalFlex) * totalWidth;
}
return left;
}
// Calculate width for a given tab index
double getTabWidth(int index) {
return (flexValues[index] / totalFlex) * totalWidth;
}
// Calculate highlight position and size based on whether it's at the edge
// First tab: flush with left edge, gap on right
// Last tab: gap on left, flush with right edge
// Middle tabs: gap on both sides
final leftInset = isFirstTab ? 0.0 : hInset;
final rightInset = isLastTab ? 0.0 : hInset;
final highlightLeft = getLeftPosition(selectedIndex) + leftInset;
final highlightWidth = getTabWidth(selectedIndex) - leftInset - rightInset;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
),
child: Stack(
children: [
// Animated sliding highlight (pill shape with full border radius)
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: highlightLeft,
width: highlightWidth,
top: 0,
bottom: 0,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: _getMediaTypeColor(colorScheme, _selectedMediaType),
borderRadius: BorderRadius.circular(8),
),
),
),
// Tab buttons row (transparent, on top of highlight)
Row(
children: types.asMap().entries.map((entry) {
final index = entry.key;
final type = entry.value;
final isSelected = _selectedMediaType == type;
return FilterChip(
selected: isSelected,
showCheckmark: false,
// Only show icon when selected
avatar: isSelected
? Icon(
return Expanded(
flex: flexValues[index],
child: GestureDetector(
onTap: () => _changeMediaType(type),
behavior: HitTestBehavior.opaque,
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
getMediaTypeIcon(type),
size: 18,
color: colorScheme.onTertiaryContainer,
)
: null,
label: Text(getMediaTypeLabel(type)),
labelStyle: TextStyle(
color: isSelected
? colorScheme.onTertiaryContainer
? colorScheme.onPrimaryContainer
: colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 6),
Flexible(
child: Text(
getMediaTypeLabel(type),
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface.withOpacity(0.7),
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
backgroundColor: colorScheme.surfaceVariant.withOpacity(0.5),
selectedColor: colorScheme.tertiaryContainer,
side: BorderSide.none,
onSelected: (_) => _changeMediaType(type),
);
}).toList(),
),
],
),
);
},
);
}
@@ -1186,25 +1403,81 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
Widget _buildCategoryChips(ColorScheme colorScheme, S l10n, int selectedIndex) {
final categories = _getCategoryLabels(l10n);
return ClipRRect(
// Calculate flex values based on label length (same approach as media type bar)
final flexValues = categories.map((label) => 10 + label.length).toList();
final totalFlex = flexValues.reduce((a, b) => a + b);
// Estimate total width: base padding (28) + text width per label
final estimatedTotalWidth = categories.fold<double>(
0,
(sum, label) => sum + 28 + label.length * 8.5,
);
const double hInset = 2.0;
final isFirstTab = selectedIndex == 0;
final isLastTab = selectedIndex == categories.length - 1;
// Calculate left position for a given index
double getLeftPosition(int index) {
double left = 0;
for (int i = 0; i < index; i++) {
left += (flexValues[i] / totalFlex) * estimatedTotalWidth;
}
return left;
}
// Calculate width for a given index
double getTabWidth(int index) {
return (flexValues[index] / totalFlex) * estimatedTotalWidth;
}
// Calculate highlight position and size based on whether it's at the edge
// First tab: flush with left edge, gap on right
// Last tab: gap on left, flush with right edge
// Middle tabs: gap on both sides
final leftInset = isFirstTab ? 0.0 : hInset;
final rightInset = isLastTab ? 0.0 : hInset;
final highlightLeft = getLeftPosition(selectedIndex) + leftInset;
final highlightWidth = getTabWidth(selectedIndex) - leftInset - rightInset;
return Container(
width: estimatedTotalWidth,
height: _filterRowHeight,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.6),
borderRadius: BorderRadius.circular(8),
child: Row(
mainAxisSize: MainAxisSize.min,
),
child: Stack(
children: [
// Animated sliding highlight
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: highlightLeft,
width: highlightWidth,
top: 0,
bottom: 0,
child: Container(
decoration: BoxDecoration(
color: _getMediaTypeColor(colorScheme, _selectedMediaType),
borderRadius: BorderRadius.circular(8),
),
),
),
// Labels row
Row(
children: categories.asMap().entries.map((entry) {
final index = entry.key;
final label = entry.value;
final isSelected = selectedIndex == index;
return Material(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceVariant.withOpacity(0.6),
child: InkWell(
return Expanded(
flex: flexValues[index],
child: GestureDetector(
onTap: () => _animateToCategory(index),
behavior: HitTestBehavior.opaque,
child: Container(
height: _filterRowHeight,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Text(
label,
style: TextStyle(
@@ -1220,6 +1493,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}).toList(),
),
],
),
);
}
@@ -1992,8 +2267,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
_logger.log('📚 Tapped series: ${series.name}, path: ${series.id}');
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AudiobookSeriesScreen(
FadeSlidePageRoute(
child: AudiobookSeriesScreen(
series: series,
heroTag: heroTag,
initialCovers: covers,
@@ -2024,8 +2299,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
_logger.log('📚 Tapped series: ${series.name}, path: ${series.id}');
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AudiobookSeriesScreen(
FadeSlidePageRoute(
child: AudiobookSeriesScreen(
series: series,
heroTag: heroTag,
initialCovers: cachedCovers,
@@ -2039,10 +2314,12 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Square cover grid with Hero animation
Hero(
tag: heroTag,
// RepaintBoundary caches the rendered grid for smooth animation
child: RepaintBoundary(
child: AspectRatio(
aspectRatio: 1.0,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(12), // Match detail screen
child: Container(
color: colorScheme.surfaceVariant,
child: _buildSeriesCoverGrid(series, colorScheme, maProvider, maxGridSize: maxCoverGridSize),
@@ -2050,6 +2327,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
),
),
),
const SizedBox(height: 8),
Text(
series.name,
@@ -3089,24 +3367,46 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Unique suffix for list view context
const heroSuffix = '_library_list';
return RepaintBoundary(
child: ListTile(
key: ValueKey(playlist.itemId),
leading: Container(
leading: Hero(
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(image: CachedNetworkImageProvider(imageUrl), fit: BoxFit.cover)
: null,
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,
),
child: imageUrl == null
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
: null,
)
: Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
title: Text(
),
),
),
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,
@@ -3115,6 +3415,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
subtitle: Text(
playlist.trackCount != null
? '${playlist.trackCount} ${l10n.tracks}'
@@ -3127,13 +3429,14 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? const Icon(Icons.favorite, color: Colors.red, 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: 'library_list',
initialImageUrl: imageUrl,
),
),
);
@@ -3148,15 +3451,20 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return GestureDetector(
// Unique suffix for grid view context
const heroSuffix = '_library_grid';
return RepaintBoundary(
child: GestureDetector(
onTap: () {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistDetailsScreen(
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
provider: playlist.provider,
itemId: playlist.itemId,
heroTagSuffix: 'library_grid',
initialImageUrl: imageUrl,
),
),
);
@@ -3165,16 +3473,22 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Hero(
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: colorScheme.surfaceVariant,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
@@ -3194,8 +3508,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
),
),
),
const SizedBox(height: 8),
Text(
Hero(
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: Material(
color: Colors.transparent,
child: Text(
playlist.name,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
@@ -3204,6 +3523,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
Text(
playlist.trackCount != null
? '${playlist.trackCount} ${l10n.tracks}'
@@ -3216,6 +3537,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
],
),
),
);
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -225,6 +225,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (selectedPlayer != null) {
try {
// Set podcast context before playing so player UI shows correct podcast name
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -259,6 +261,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
// Set podcast context before playing so player UI shows correct podcast name
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
},
).whenComplete(() {
+175 -186
View File
@@ -15,6 +15,7 @@ import '../widgets/artist_avatar.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../utils/search/search_scoring.dart';
import 'album_details_screen.dart';
import 'artist_details_screen.dart';
import 'playlist_details_screen.dart';
@@ -128,11 +129,21 @@ class SearchScreenState extends State<SearchScreen> {
// PERF: Cache available filters to avoid recalculating during build
List<String>? _cachedAvailableFilters;
// Advanced search scorer with fuzzy matching, stopword removal, etc.
final SearchScorer _searchScorer = SearchScorer();
// Scroll-to-hide search bar (vertical scroll only)
bool _isSearchBarVisible = true;
double _lastVerticalScrollOffset = 0;
static const double _scrollThreshold = 10.0;
// Filter chip position tracking for animated sliding highlight
final Map<String, GlobalKey> _filterKeys = {};
final Map<String, double> _filterWidths = {};
final Map<String, double> _filterPositions = {};
double _highlightLeft = 0;
double _highlightWidth = 80; // Default width until measured
@override
void initState() {
super.initState();
@@ -181,6 +192,39 @@ class SearchScreenState extends State<SearchScreen> {
}
}
/// Measure filter chip positions and update highlight
void _measureFilterPositions() {
final filters = _getAvailableFilters();
double left = 0;
for (final filter in filters) {
final key = _filterKeys[filter];
if (key?.currentContext != null) {
final box = key!.currentContext!.findRenderObject() as RenderBox?;
if (box != null && box.hasSize) {
_filterWidths[filter] = box.size.width;
_filterPositions[filter] = left;
left += box.size.width;
}
}
}
}
/// Update highlight position for the active filter
void _updateHighlightPosition(String activeFilter) {
_measureFilterPositions();
final position = _filterPositions[activeFilter];
final width = _filterWidths[activeFilter];
if (position != null && width != null) {
setState(() {
_highlightLeft = position;
_highlightWidth = width;
});
}
}
/// Scroll filter bar to keep active filter visible
/// Only scrolls when the filter would be obscured (off-screen)
/// Uses post-frame callback to avoid interfering with PageView animation
@@ -196,30 +240,35 @@ class SearchScreenState extends State<SearchScreen> {
// If all filters fit on screen, don't scroll at all
if (maxScroll <= 0) return;
// Approximate width per filter chip (padding + text)
const chipWidth = 80.0;
const horizontalPadding = 16.0;
final filters = _getAvailableFilters();
if (filterIndex < 0 || filterIndex >= filters.length) return;
final filter = filters[filterIndex];
// Update highlight position
_updateHighlightPosition(filter);
// Use measured positions if available, fallback to estimate
final chipLeft = _filterPositions[filter] ?? (filterIndex * 80.0);
final chipWidth = _filterWidths[filter] ?? 80.0;
final chipRight = chipLeft + chipWidth;
final currentScroll = _filterScrollController.offset;
final viewportWidth = _filterScrollController.position.viewportDimension;
// Calculate the left and right edges of the active filter chip
final chipLeft = (filterIndex * chipWidth);
final chipRight = chipLeft + chipWidth;
// Calculate what's currently visible in the viewport
final visibleLeft = currentScroll;
final visibleRight = currentScroll + viewportWidth - (horizontalPadding * 2);
final visibleRight = currentScroll + viewportWidth;
// Only scroll if the chip is actually obscured
double? targetOffset;
if (chipRight > visibleRight) {
// Chip is cut off on the right - scroll right just enough to show it
targetOffset = chipRight - viewportWidth + (horizontalPadding * 2);
// Chip is cut off on the right - scroll right to show it with padding
targetOffset = chipRight - viewportWidth + 8;
} else if (chipLeft < visibleLeft) {
// Chip is cut off on the left - scroll left just enough to show it
targetOffset = chipLeft;
// Chip is cut off on the left - scroll left to show it with padding
targetOffset = chipLeft - 8;
}
// Only animate if we need to scroll
@@ -750,151 +799,15 @@ class SearchScreenState extends State<SearchScreen> {
);
}
/// Calculate relevance score for a media item based on query match
/// Calculate relevance score for a media item based on query match.
///
/// Uses advanced scoring with:
/// - Stopword removal ("the Ramones" finds "Ramones")
/// - Fuzzy matching (typo tolerance via Jaro-Winkler)
/// - N-gram matching (partial matches)
/// - Reverse matching (query contains result name)
double _calculateRelevanceScore(MediaItem item, String query) {
final queryLower = query.toLowerCase().trim();
if (queryLower.isEmpty) return 0;
double score = 0;
final nameLower = item.name.toLowerCase();
// Primary name matching
if (nameLower == queryLower) {
score = 100; // Exact match
} else if (nameLower.startsWith(queryLower)) {
score = 80; // Starts with query
} else if (_matchesWordBoundary(nameLower, queryLower)) {
score = 60; // Contains query at word boundary
} else if (nameLower.contains(queryLower)) {
score = 40; // Contains query anywhere
} else {
score = 20; // Fuzzy/partial match (MA returned it, so some relevance)
}
// Bonus for library items (Album has inLibrary property)
if (item is Album && item.inLibrary) {
score += 10;
}
// Bonus for favorites
if (item.favorite == true) {
score += 5;
}
// Secondary field matching (artist/author/creator name)
if (item is Album) {
final artistLower = item.artistsString.toLowerCase();
if (artistLower == queryLower) {
score += 15; // Artist exact match
} else if (artistLower.contains(queryLower)) {
score += 8; // Artist contains query
}
} else if (item is Track) {
final artistLower = item.artistsString.toLowerCase();
if (artistLower == queryLower) {
score += 15;
} else if (artistLower.contains(queryLower)) {
score += 8;
}
// Also check album name for tracks
if (item.album?.name != null) {
final albumLower = item.album!.name.toLowerCase();
if (albumLower.contains(queryLower)) {
score += 5;
}
}
} else if (item is Audiobook) {
// Check audiobook author names
final authorLower = item.authorsString.toLowerCase();
if (authorLower == queryLower) {
score += 15; // Author exact match
} else if (authorLower.contains(queryLower)) {
score += 8; // Author contains query
}
// Also check narrator names
final narratorLower = item.narratorsString.toLowerCase();
if (narratorLower.contains(queryLower)) {
score += 5;
}
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
// Check podcast metadata for author/creator
final metadata = item.metadata;
bool foundCreatorMatch = false;
if (metadata != null) {
final author = (metadata['author'] as String? ?? '').toLowerCase();
final publisher = (metadata['publisher'] as String? ?? '').toLowerCase();
final owner = (metadata['owner'] as String? ?? '').toLowerCase();
final creator = (metadata['creator'] as String? ?? '').toLowerCase();
// Check all possible creator fields
final creatorFields = [author, publisher, owner, creator].where((s) => s.isNotEmpty);
bool foundExact = false;
bool foundContains = false;
for (final field in creatorFields) {
if (field == queryLower) {
foundExact = true;
break;
} else if (field.contains(queryLower)) {
foundContains = true;
}
}
if (foundExact) {
score += 15; // Creator exact match
foundCreatorMatch = true;
} else if (foundContains) {
score += 8; // Creator contains query
foundCreatorMatch = true;
}
// Also check description for keyword matches
final description = (metadata['description'] as String? ?? '').toLowerCase();
if (description.contains(queryLower)) {
score += 5; // Increased from 3 to match other media types
}
}
// Fallback: If no creator metadata matched, but query matches strongly in name,
// give a boost. Podcast names often include the host's name (e.g., "The Louis Theroux Podcast")
if (!foundCreatorMatch && nameLower.contains(queryLower)) {
// Multi-word query that matches in name suggests creator/host name
if (queryLower.contains(' ')) {
// Calculate how prominent the query is in the name
// e.g., "Louis Theroux Interviews" with query "Louis Theroux" = 14/23 = ~60%
final prominence = queryLower.length / nameLower.length;
if (prominence >= 0.5) {
score += 15; // Query is >50% of name - very strong signal
} else if (prominence >= 0.3) {
score += 12; // Query is 30-50% of name - strong signal
} else {
score += 8; // Query in name but less prominent
}
} else {
score += 5; // Single word match
}
}
}
return score;
}
/// Check if query matches at a word boundary in text
/// Handles both single-word and multi-word queries
bool _matchesWordBoundary(String text, String query) {
// For multi-word queries, check if query appears at start of a word in text
// e.g., "The Louis Theroux Podcast" contains "louis theroux" at word boundary
if (query.contains(' ')) {
// Check if text starts with query or contains query after whitespace
if (text.startsWith(query)) return true;
if (text.contains(' $query')) return true;
return false;
}
// For single-word queries, check individual words
final words = text.split(RegExp(r'\s+'));
for (final word in words) {
if (word.startsWith(query)) return true;
}
return false;
return _searchScorer.scoreItem(item, query);
}
/// Extract artists from tracks/albums that match the query but aren't in direct results
@@ -907,8 +820,10 @@ class SearchScreenState extends State<SearchScreen> {
) {
if (query.isEmpty) return [];
final queryLower = query.toLowerCase();
final queryWords = queryLower.split(RegExp(r'\s+'));
// Use normalizer to get tokens with stopwords removed
// This way "the Beatles" becomes ["beatles"] for better matching
final normalizedQuery = _searchScorer.normalizer.normalizeQuery(query);
final queryWords = normalizedQuery.tokensNoStop;
// Create a set of existing artist identifiers to avoid duplicates
final existingArtistKeys = <String>{};
@@ -942,6 +857,7 @@ class SearchScreenState extends State<SearchScreen> {
}
// Filter candidates: only include if artist name contains any query word
// (stopwords already removed from queryWords)
final crossRefArtists = <Artist>[];
for (final artist in candidateArtists.values) {
final artistLower = artist.name.toLowerCase();
@@ -1063,7 +979,7 @@ class SearchScreenState extends State<SearchScreen> {
return items;
}
/// Build joined segmented filter selector (like library media type selector)
/// Build joined segmented filter selector with animated sliding highlight
Widget _buildFilterSelector(ColorScheme colorScheme) {
final filters = _getAvailableFilters();
final l10n = S.of(context)!;
@@ -1082,27 +998,75 @@ class SearchScreenState extends State<SearchScreen> {
}
}
// No ClipRRect here - parent container handles clipping with rounded corners
// Ensure GlobalKeys exist for all filters
for (final filter in filters) {
_filterKeys.putIfAbsent(filter, () => GlobalKey());
}
// Wrap in ValueListenableBuilder for efficient rebuilds on filter change
return ValueListenableBuilder<String>(
valueListenable: _activeFilterNotifier,
builder: (context, activeFilter, _) {
return Row(
// Measure positions after build
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_measureFilterPositions();
// Update highlight on first build or when filters change
final position = _filterPositions[activeFilter];
final width = _filterWidths[activeFilter];
if (position != null && width != null &&
(_highlightLeft != position || _highlightWidth != width)) {
setState(() {
_highlightLeft = position;
_highlightWidth = width;
});
}
}
});
final selectedIndex = filters.indexOf(activeFilter);
final isFirstTab = selectedIndex == 0;
final isLastTab = selectedIndex == filters.length - 1;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(10),
),
child: IntrinsicHeight(
child: Stack(
children: [
// Animated sliding highlight
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: _highlightLeft + (isFirstTab ? 0 : 2),
width: _highlightWidth - (isFirstTab ? 0 : 2) - (isLastTab ? 0 : 2),
top: 0,
bottom: 0,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
),
),
// Filter buttons row (transparent, on top of highlight)
Row(
mainAxisSize: MainAxisSize.min,
children: filters.map((filter) {
final isSelected = activeFilter == filter;
return Material(
// Use theme-aware colors for light/dark mode support
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceVariant.withOpacity(0.6),
child: InkWell(
return GestureDetector(
key: _filterKeys[filter],
onTap: () {
// Only update ValueNotifier - no setState needed
_activeFilterNotifier.value = filter;
_animateToFilter(filter);
},
child: Padding(
behavior: HitTestBehavior.opaque,
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Text(
getLabel(filter),
@@ -1115,9 +1079,12 @@ class SearchScreenState extends State<SearchScreen> {
),
),
),
),
);
}).toList(),
),
],
),
),
);
},
);
@@ -1695,30 +1662,49 @@ class SearchScreenState extends State<SearchScreen> {
final isExpanded = _expandedPlaylistId == playlistId;
final isInLib = _isInLibrary(playlist);
// Unique suffix for search context
const heroSuffix = '_search';
return RepaintBoundary(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: ValueKey(playlistId),
leading: Container(
leading: Hero(
tag: HeroTags.playlistCover + playlistId + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.queue_music_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: null,
: Icon(
Icons.queue_music_rounded,
color: colorScheme.onSurfaceVariant,
),
child: imageUrl == null
? Icon(Icons.queue_music_rounded, color: colorScheme.onSurfaceVariant)
: null,
),
title: Text(
),
),
title: Hero(
tag: HeroTags.playlistTitle + playlistId + heroSuffix,
child: Material(
color: Colors.transparent,
child: Text(
playlist.name,
style: TextStyle(
color: colorScheme.onSurface,
@@ -1727,6 +1713,8 @@ class SearchScreenState extends State<SearchScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
subtitle: showType
? Row(
children: [
@@ -1760,13 +1748,14 @@ class SearchScreenState extends State<SearchScreen> {
if (isExpanded) {
setState(() => _expandedPlaylistId = null);
} else {
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistDetailsScreen(
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
provider: playlist.provider,
itemId: playlist.itemId,
heroTagSuffix: 'search',
initialImageUrl: imageUrl,
),
),
);
+83
View File
@@ -29,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;
@@ -41,6 +44,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
bool _volumePrecisionMode = true;
// Hint settings
bool _showHints = true;
// Library settings
@@ -70,6 +74,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();
@@ -92,6 +99,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// 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();
@@ -107,6 +115,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContinueAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
@@ -115,6 +126,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_libraryEnabled = libraryEnabled;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
});
@@ -150,6 +162,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
case 'favorite-tracks':
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
case 'favorite-playlists':
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
case 'favorite-radio-stations':
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
case 'favorite-podcasts':
return {'title': s.favoritePodcasts, 'subtitle': s.showFavoritePodcasts};
default:
return {'title': rowId, 'subtitle': ''};
}
@@ -176,6 +194,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
return _showFavoriteArtists;
case 'favorite-tracks':
return _showFavoriteTracks;
case 'favorite-playlists':
return _showFavoritePlaylists;
case 'favorite-radio-stations':
return _showFavoriteRadioStations;
case 'favorite-podcasts':
return _showFavoritePodcasts;
default:
return false;
}
@@ -221,6 +245,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteTracks = value;
SettingsService.setShowFavoriteTracks(value);
break;
case 'favorite-playlists':
_showFavoritePlaylists = value;
SettingsService.setShowFavoritePlaylists(value);
break;
case 'favorite-radio-stations':
_showFavoriteRadioStations = value;
SettingsService.setShowFavoriteRadioStations(value);
break;
case 'favorite-podcasts':
_showFavoritePodcasts = value;
SettingsService.setShowFavoritePodcasts(value);
break;
}
});
}
@@ -287,13 +323,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: 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
@@ -635,6 +684,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);
},
),
],
),
);
@@ -708,6 +766,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Volume precision mode',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hold still while adjusting volume for fine control',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _volumePrecisionMode,
onChanged: (value) {
setState(() => _volumePrecisionMode = value);
SettingsService.setVolumePrecisionMode(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
+2 -1
View File
@@ -100,7 +100,8 @@ class AuthManager {
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local');
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Try to detect auth strategy for a given URL
+77
View File
@@ -20,9 +20,15 @@ class CacheService {
List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists;
List<Album>? _cachedDiscoverAlbums;
List<Audiobook>? _cachedInProgressAudiobooks;
List<Audiobook>? _cachedDiscoverAudiobooks;
List<AudiobookSeries>? _cachedDiscoverSeries;
DateTime? _recentAlbumsLastFetched;
DateTime? _discoverArtistsLastFetched;
DateTime? _discoverAlbumsLastFetched;
DateTime? _inProgressAudiobooksLastFetched;
DateTime? _discoverAudiobooksLastFetched;
DateTime? _discoverSeriesLastFetched;
// Detail screen caching
final Map<String, List<Track>> _albumTracksCache = {};
@@ -112,11 +118,82 @@ class CacheService {
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
}
/// Check if in-progress audiobooks cache is valid
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedInProgressAudiobooks != null &&
_inProgressAudiobooksLastFetched != null &&
now.difference(_inProgressAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached in-progress audiobooks
List<Audiobook>? getCachedInProgressAudiobooks() => _cachedInProgressAudiobooks;
/// Set cached in-progress audiobooks
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
_cachedInProgressAudiobooks = audiobooks;
_inProgressAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
}
/// Check if discover audiobooks cache is valid
bool isDiscoverAudiobooksCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoverAudiobooks != null &&
_discoverAudiobooksLastFetched != null &&
now.difference(_discoverAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discover audiobooks
List<Audiobook>? getCachedDiscoverAudiobooks() => _cachedDiscoverAudiobooks;
/// Set cached discover audiobooks
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
_cachedDiscoverAudiobooks = audiobooks;
_discoverAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
}
/// Check if discover series cache is valid
bool isDiscoverSeriesCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoverSeries != null &&
_discoverSeriesLastFetched != null &&
now.difference(_discoverSeriesLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discover series
List<AudiobookSeries>? getCachedDiscoverSeries() => _cachedDiscoverSeries;
/// Set cached discover series
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
_cachedDiscoverSeries = series;
_discoverSeriesLastFetched = DateTime.now();
_logger.log('✅ Cached ${series.length} discover series');
}
/// Invalidate audiobook caches
void invalidateAudiobookCaches() {
_cachedInProgressAudiobooks = null;
_inProgressAudiobooksLastFetched = null;
_cachedDiscoverAudiobooks = null;
_discoverAudiobooksLastFetched = null;
_cachedDiscoverSeries = null;
_discoverSeriesLastFetched = null;
_logger.log('🗑️ Audiobook caches invalidated');
}
/// Invalidate home screen cache (call on pull-to-refresh)
void invalidateHomeCache() {
_recentAlbumsLastFetched = null;
_discoverArtistsLastFetched = null;
_discoverAlbumsLastFetched = null;
_inProgressAudiobooksLastFetched = null;
_discoverAudiobooksLastFetched = null;
_discoverSeriesLastFetched = null;
_logger.log('🗑️ Home screen cache invalidated');
}
+82
View File
@@ -2453,6 +2453,71 @@ class MusicAssistantAPI {
}
}
/// Delete an item from the queue
Future<void> queueCommandDeleteItem(String queueId, String itemId) async {
try {
await _sendCommand(
'player_queues/delete_item',
args: {
'queue_id': queueId,
'item_id_or_index': itemId,
},
);
} catch (e) {
_logger.log('Error deleting queue item: $e');
rethrow;
}
}
/// Move an item in the queue by a relative position shift
/// pos_shift > 0: move down, pos_shift < 0: move up, pos_shift = 0: move to next
Future<void> queueCommandMoveItem(String queueId, String itemId, int posShift) async {
try {
await _sendCommand(
'player_queues/move_item',
args: {
'queue_id': queueId,
'queue_item_id': itemId,
'pos_shift': posShift,
},
);
} catch (e) {
_logger.log('Error moving queue item: $e');
rethrow;
}
}
/// Play a specific item in the queue by its queue_item_id
Future<void> queueCommandPlayIndex(String queueId, String queueItemId) async {
try {
await _sendCommand(
'player_queues/play_index',
args: {
'queue_id': queueId,
'index': queueItemId,
},
);
} catch (e) {
_logger.log('Error playing queue item: $e');
rethrow;
}
}
/// Clear all items from the queue
Future<void> queueCommandClear(String queueId) async {
try {
await _sendCommand(
'player_queues/clear',
args: {
'queue_id': queueId,
},
);
} catch (e) {
_logger.log('Error clearing queue: $e');
rethrow;
}
}
// ============================================================================
// BUILT-IN PLAYER MANAGEMENT
// ============================================================================
@@ -2496,6 +2561,23 @@ class MusicAssistantAPI {
return _eventStreams['media_item_deleted']!.stream;
}
/// Stream of queue_updated events (for queue metadata changes)
Stream<Map<String, dynamic>> get queueUpdatedEvents {
if (!_eventStreams.containsKey('queue_updated')) {
_eventStreams['queue_updated'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['queue_updated']!.stream;
}
/// Stream of queue_items_updated events (for queue item add/remove/reorder)
/// This is critical for keeping queue UI in sync, especially in radio mode
Stream<Map<String, dynamic>> get queueItemsUpdatedEvents {
if (!_eventStreams.containsKey('queue_items_updated')) {
_eventStreams['queue_items_updated'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['queue_items_updated']!.stream;
}
/// Register this device as a player with retry logic
/// CRITICAL: This creates a player config in MA's settings.json
/// The server expects: player_id and player_name
+66 -1
View File
@@ -35,6 +35,9 @@ class SettingsService {
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
static const String _keyShowFavoritePlaylists = 'show_favorite_playlists';
static const String _keyShowFavoriteRadioStations = 'show_favorite_radio_stations';
static const String _keyShowFavoritePodcasts = 'show_favorite_podcasts';
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
@@ -49,6 +52,9 @@ class SettingsService {
'favorite-albums',
'favorite-artists',
'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
'favorite-podcasts',
];
// View Mode Settings
@@ -84,6 +90,9 @@ class SettingsService {
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
// Volume Precision Mode Settings
static const String _keyVolumePrecisionMode = 'volume_precision_mode'; // Enable hold-to-precision volume control
// Podcast Cover Cache (iTunes URLs for high-res artwork)
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
@@ -490,6 +499,36 @@ class SettingsService {
await prefs.setBool(_keyShowFavoriteTracks, show);
}
static Future<bool> getShowFavoritePlaylists() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoritePlaylists) ?? false;
}
static Future<void> setShowFavoritePlaylists(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoritePlaylists, show);
}
static Future<bool> getShowFavoriteRadioStations() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoriteRadioStations) ?? false;
}
static Future<void> setShowFavoriteRadioStations(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoriteRadioStations, show);
}
static Future<bool> getShowFavoritePodcasts() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowFavoritePodcasts) ?? false;
}
static Future<void> setShowFavoritePodcasts(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowFavoritePodcasts, show);
}
// Library Artists Filter - show only artists that have albums in library
static Future<bool> getShowOnlyArtistsWithAlbums() async {
final prefs = await SharedPreferences.getInstance();
@@ -508,7 +547,17 @@ class SettingsService {
if (json != null) {
try {
final List<dynamic> decoded = jsonDecode(json);
return decoded.cast<String>();
final savedOrder = decoded.cast<String>();
// Add any new rows from defaultHomeRowOrder that aren't in saved order
// This ensures new row types appear for existing users
final missingRows = defaultHomeRowOrder
.where((row) => !savedOrder.contains(row))
.toList();
if (missingRows.isNotEmpty) {
return [...savedOrder, ...missingRows];
}
return savedOrder;
} catch (_) {
return List.from(defaultHomeRowOrder);
}
@@ -824,6 +873,22 @@ class SettingsService {
await prefs.setBool(_keyHasCompletedOnboarding, completed);
}
// Volume Precision Mode Settings
/// Get whether volume precision mode is enabled (default: true)
/// When enabled, holding still while adjusting volume enters precision mode
/// for fine-grained control (10x more precise)
static Future<bool> getVolumePrecisionMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
}
/// Set whether volume precision mode is enabled
static Future<void> setVolumePrecisionMode(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyVolumePrecisionMode, enabled);
}
// Podcast Cover Cache (iTunes URLs for high-res artwork)
// Stored as JSON: {"podcastId": "itunesUrl", ...}
+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());
}
}
+7 -12
View File
@@ -38,6 +38,12 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
@override
void initState() {
super.initState();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedAlbums?.call();
if (cached != null && cached.isNotEmpty) {
_albums = cached;
_isLoading = false;
}
_loadAlbums();
}
@@ -45,18 +51,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
if (_hasLoaded) return;
_hasLoaded = true;
// 1. Try to get cached data synchronously for instant display
final cachedAlbums = widget.getCachedAlbums?.call();
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
if (mounted) {
setState(() {
_albums = cachedAlbums;
_isLoading = false;
});
}
}
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
// Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshAlbums = await widget.loadAlbums();
if (mounted && freshAlbums.isNotEmpty) {
+7 -12
View File
@@ -38,6 +38,12 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
@override
void initState() {
super.initState();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedArtists?.call();
if (cached != null && cached.isNotEmpty) {
_artists = cached;
_isLoading = false;
}
_loadArtists();
}
@@ -45,18 +51,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
if (_hasLoaded) return;
_hasLoaded = true;
// 1. Try to get cached data synchronously for instant display
final cachedArtists = widget.getCachedArtists?.call();
if (cachedArtists != null && cachedArtists.isNotEmpty) {
if (mounted) {
setState(() {
_artists = cachedArtists;
_isLoading = false;
});
}
}
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
// Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshArtists = await widget.loadArtists();
if (mounted && freshArtists.isNotEmpty) {
+81 -68
View File
@@ -14,6 +14,8 @@ class AudiobookRow extends StatefulWidget {
final Future<List<Audiobook>> Function() loadAudiobooks;
final String? heroTagSuffix;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Audiobook>? Function()? getCachedAudiobooks;
const AudiobookRow({
super.key,
@@ -21,6 +23,7 @@ class AudiobookRow extends StatefulWidget {
required this.loadAudiobooks,
this.heroTagSuffix,
this.rowHeight,
this.getCachedAudiobooks,
});
@override
@@ -28,8 +31,8 @@ class AudiobookRow extends StatefulWidget {
}
class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClientMixin {
late Future<List<Audiobook>> _audiobooksFuture;
List<Audiobook>? _cachedAudiobooks;
List<Audiobook> _audiobooks = [];
bool _isLoading = true;
bool _hasLoaded = false;
bool _hasPrecachedAudiobooks = false;
@@ -39,18 +42,36 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
@override
void initState() {
super.initState();
_loadAudiobooksOnce();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedAudiobooks?.call();
if (cached != null && cached.isNotEmpty) {
_audiobooks = cached;
_isLoading = false;
}
_loadAudiobooks();
}
void _loadAudiobooksOnce() {
if (!_hasLoaded) {
_audiobooksFuture = widget.loadAudiobooks().then((audiobooks) {
_cachedAudiobooks = audiobooks;
// Pre-cache images for smooth Hero animations
_precacheAudiobookImages(audiobooks);
return audiobooks;
});
Future<void> _loadAudiobooks() async {
if (_hasLoaded) return;
_hasLoaded = true;
// Load fresh data (always update - fresh data may have updated progress)
try {
final freshAudiobooks = await widget.loadAudiobooks();
if (mounted && freshAudiobooks.isNotEmpty) {
setState(() {
_audiobooks = freshAudiobooks;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheAudiobookImages(freshAudiobooks);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
@@ -80,10 +101,57 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme, MusicAssistantProvider maProvider) {
// Only show loading if we have no data at all
if (_audiobooks.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_audiobooks.isEmpty) {
return Center(
child: Text(
'No audiobooks found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _audiobooks.length,
itemExtent: itemExtent,
cacheExtent: 500,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemBuilder: (context, index) {
final audiobook = _audiobooks[index];
return Container(
key: ValueKey(audiobook.uri ?? audiobook.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: _AudiobookCard(
audiobook: audiobook,
heroTagSuffix: widget.heroTagSuffix ?? 'home_${widget.title.replaceAll(' ', '_').toLowerCase()}',
maProvider: maProvider,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('AudiobookRow:${widget.title}');
super.build(context);
super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final maProvider = context.read<MusicAssistantProvider>();
@@ -109,62 +177,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
),
),
Expanded(
child: FutureBuilder<List<Audiobook>>(
future: _audiobooksFuture,
builder: (context, snapshot) {
final audiobooks = snapshot.data ?? _cachedAudiobooks;
if (audiobooks == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && audiobooks == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (audiobooks == null || audiobooks.isEmpty) {
return Center(
child: Text(
'No audiobooks found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: audiobooks.length,
itemExtent: itemExtent,
cacheExtent: 500,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemBuilder: (context, index) {
final audiobook = audiobooks[index];
return Container(
key: ValueKey(audiobook.uri ?? audiobook.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: _AudiobookCard(
audiobook: audiobook,
heroTagSuffix: widget.heroTagSuffix ?? 'home_${widget.title.replaceAll(' ', '_').toLowerCase()}',
maProvider: maProvider,
),
);
},
),
);
},
),
child: _buildContent(contentHeight, colorScheme, maProvider),
),
],
),
File diff suppressed because it is too large Load Diff
+67 -37
View File
@@ -8,6 +8,7 @@ import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import 'expandable_player.dart';
import 'player/mini_player_content.dart' show MiniPlayerLayout;
import 'player/player_reveal_overlay.dart';
@@ -67,13 +68,14 @@ class BottomSpacing {
static double get withMiniPlayer => navBarHeight + miniPlayerHeight + 22.0;
}
/// ValueNotifier for player expansion progress (0.0 to 1.0) and background color
/// ValueNotifier for player expansion progress (0.0 to 1.0) and colors
class PlayerExpansionState {
final double progress;
final Color? backgroundColor;
PlayerExpansionState(this.progress, this.backgroundColor);
final Color? primaryColor;
PlayerExpansionState(this.progress, this.backgroundColor, this.primaryColor);
}
final playerExpansionNotifier = ValueNotifier<PlayerExpansionState>(PlayerExpansionState(0.0, null));
final playerExpansionNotifier = ValueNotifier<PlayerExpansionState>(PlayerExpansionState(0.0, null, null));
/// Wrapper widget that provides a global player overlay above all navigation.
///
@@ -99,6 +101,21 @@ class GlobalPlayerOverlay extends StatefulWidget {
static bool get isPlayerExpanded =>
globalPlayerKey.currentState?.isExpanded ?? false;
/// Check if the queue panel is currently open (animation value > 0.5)
static bool get isQueuePanelOpen =>
globalPlayerKey.currentState?.isQueuePanelOpen ?? false;
/// Check if the queue panel is intended to be open (target state)
/// Use this for back gesture handling to avoid timing issues during animations
static bool get isQueuePanelTargetOpen =>
globalPlayerKey.currentState?.isQueuePanelTargetOpen ?? false;
/// Close the queue panel if open
/// [withHaptic]: Set to false for Android back gesture (system provides haptic)
static void closeQueuePanel({bool withHaptic = true}) {
globalPlayerKey.currentState?.closeQueuePanel(withHaptic: withHaptic);
}
/// Get the current expansion progress (0.0 to 1.0)
static double get expansionProgress =>
globalPlayerKey.currentState?.expansionProgress ?? 0.0;
@@ -473,23 +490,57 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
builder: (context, _) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
// Use adaptive primary color for bottom nav when adaptive theme is enabled
final sourceColor = themeProvider.adaptiveTheme
? themeProvider.adaptivePrimaryColor
: colorScheme.primary;
// Use cached color computation to avoid expensive HSL operations during scroll
final isDark = Theme.of(context).brightness == Brightness.dark;
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Base background: use adaptive surface color if available, otherwise default surface
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
? themeProvider.adaptiveSurfaceColor!
: colorScheme.surface;
return ValueListenableBuilder<PlayerExpansionState>(
valueListenable: playerExpansionNotifier,
// Pass BottomNavigationBar as child to avoid rebuilding it every frame
builder: (context, expansionState, _) {
// Nav bar color logic - only use adaptive colors when:
// 1. Player is expanding/expanded, OR
// 2. On a detail screen (isOnDetailScreen)
// On home screen with collapsed player: always use default theme colors
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
// Nav bar background color
final Color navBgColor;
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
// Player is expanding - blend from surface to player's adaptive color
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
} else if (useAdaptiveColors) {
// On a detail screen - use adaptive surface color for nav bar
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
navBgColor = adaptiveBg ?? colorScheme.surface;
} else {
// Home screen with collapsed player - use default surface color
navBgColor = colorScheme.surface;
}
// Icon color - only use adaptive colors when appropriate
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
? themeProvider.adaptiveColors!.primary
: colorScheme.primary;
// Blend icon color with player's primary color during expansion
Color sourceColor = baseSourceColor;
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
}
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
return Container(
decoration: BoxDecoration(
color: navBgColor,
boxShadow: expansionState.progress < 0.5
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
]
: null,
),
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
@@ -534,27 +585,6 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
),
],
),
builder: (context, expansionState, navBar) {
// Only compute colors during animation - this is the hot path
final navBgColor = expansionState.progress > 0 && expansionState.backgroundColor != null
? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)!
: baseBgColor;
// Use AnimatedContainer for smoother transitions instead of rebuilding
return Container(
decoration: BoxDecoration(
color: navBgColor,
boxShadow: expansionState.progress < 0.5
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
]
: null,
),
child: navBar, // Reuse pre-built navigation bar
);
},
);
+2 -2
View File
@@ -8,8 +8,8 @@ class MiniPlayerLayout {
static const double textLeftOffset = 10.0; // Gap between art and text
static const double textLeft = artSize + textLeftOffset; // 82px
// 3-line layout: track, artist, player
static const double primaryTop = 5.0; // raised slightly from 7
static const double secondaryTop = 26.0; // raised by 1
static const double primaryTop = 9.0;
static const double secondaryTop = 26.0;
static const double tertiaryTop = 46.0;
// 2-line layout (evenly spaced): player name, hint
// Height 72 / 3 = 24px spacing. Line 1 center at 24, Line 2 center at 48
+172 -25
View File
@@ -1,9 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
import '../../l10n/app_localizations.dart';
import '../../services/debug_logger.dart';
import '../../services/settings_service.dart';
final _volumeLogger = DebugLogger();
/// A player card that matches the mini player's visual style.
/// Used in the player reveal overlay when swiping down or tapping the device button.
@@ -23,10 +28,23 @@ class PlayerCard extends StatefulWidget {
final VoidCallback? onSkipNext;
final VoidCallback? onPower;
final ValueChanged<double>? onVolumeChange;
final ValueChanged<bool>? onPrecisionModeChanged;
// Pastel yellow for grouped players
static const Color groupBorderColor = Color(0xFFFFF59D);
// Precision mode settings
static const int precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
static const double precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
static const double precisionSensitivity = 0.1; // 10x more precise (full swipe = 10% change)
// PERF Phase 4: Pre-cached BoxShadow to avoid allocation per frame
static const BoxShadow cardShadow = BoxShadow(
color: Color(0x33000000), // 20% black
blurRadius: 8,
offset: Offset(0, 2),
);
const PlayerCard({
super.key,
required this.player,
@@ -43,6 +61,7 @@ class PlayerCard extends StatefulWidget {
this.onSkipNext,
this.onPower,
this.onVolumeChange,
this.onPrecisionModeChanged,
});
@override
@@ -54,7 +73,70 @@ class _PlayerCardState extends State<PlayerCard> {
double _dragVolumeLevel = 0.0;
double _cardWidth = 0.0;
int _lastVolumeUpdateTime = 0;
int _lastDragEndTime = 0; // Track when last drag ended for consecutive swipes
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
static const int _precisionThrottleMs = 50; // Faster updates in precision mode
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
// Precision mode state
bool _inPrecisionMode = false;
Timer? _precisionTimer;
Offset? _lastDragPosition;
double _lastLocalX = 0.0; // Last local X position during drag (for precision mode)
bool _precisionModeEnabled = true; // From settings
double _precisionZoomCenter = 0.0; // Volume level when precision mode started
double _precisionStartX = 0.0; // Finger X position when precision mode started
@override
void initState() {
super.initState();
_loadPrecisionModeSetting();
}
Future<void> _loadPrecisionModeSetting() async {
final enabled = await SettingsService.getVolumePrecisionMode();
if (mounted) {
setState(() {
_precisionModeEnabled = enabled;
});
}
}
@override
void dispose() {
_precisionTimer?.cancel();
super.dispose();
}
void _enterPrecisionMode() {
if (_inPrecisionMode) return;
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
setState(() {
_inPrecisionMode = true;
_precisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
_precisionStartX = _lastLocalX; // Capture finger position at entry
});
widget.onPrecisionModeChanged?.call(true);
_volumeLogger.debug(
'PRECISION_MODE [${widget.player.name}]: ENTERED at ${(_precisionZoomCenter * 100).round()}%',
context: 'Volume',
);
}
void _exitPrecisionMode() {
_precisionTimer?.cancel();
_precisionTimer = null;
if (!_inPrecisionMode) return;
setState(() {
_inPrecisionMode = false;
});
widget.onPrecisionModeChanged?.call(false);
_volumeLogger.debug(
'PRECISION_MODE [${widget.player.name}]: EXITED',
context: 'Volume',
);
}
@override
Widget build(BuildContext context) {
@@ -67,7 +149,10 @@ class _PlayerCardState extends State<PlayerCard> {
final filledColor = widget.backgroundColor;
final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
return LayoutBuilder(
// RepaintBoundary isolates this card's repaint from parent transform animations
// This improves performance during the staggered reveal animation
return RepaintBoundary(
child: LayoutBuilder(
builder: (context, constraints) {
_cardWidth = constraints.maxWidth;
@@ -92,13 +177,8 @@ class _PlayerCardState extends State<PlayerCard> {
decoration: BoxDecoration(
color: unfilledColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
// PERF Phase 4: Use pre-cached static BoxShadow
boxShadow: const [PlayerCard.cardShadow],
),
clipBehavior: Clip.antiAlias,
child: Align(
@@ -133,6 +213,7 @@ class _PlayerCardState extends State<PlayerCard> {
),
);
},
),
);
}
@@ -142,13 +223,8 @@ class _PlayerCardState extends State<PlayerCard> {
decoration: BoxDecoration(
color: widget.backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
// PERF Phase 4: Use pre-cached static BoxShadow
boxShadow: const [PlayerCard.cardShadow],
),
clipBehavior: Clip.antiAlias,
child: Row(
@@ -282,25 +358,83 @@ class _PlayerCardState extends State<PlayerCard> {
}
void _onDragStart(DragStartDetails details) {
// Get current volume from player
final currentVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
// For consecutive swipes, use local volume (API may not have updated player state yet)
final now = DateTime.now().millisecondsSinceEpoch;
final timeSinceLastDrag = now - _lastDragEndTime;
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
// Use local volume if:
// 1. We have a local override (we've swiped before) AND
// 2. We're within the consecutive swipe window (5 seconds)
// Otherwise, read fresh from player state
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
final playerVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
final startVolume = useLocalVolume
? _dragVolumeLevel // Continue from where last swipe ended
: playerVolume; // Fresh from player
_volumeLogger.debug(
'DRAG_START [${widget.player.name}]: '
'useLocal=$useLocalVolume, hasOverride=$_hasLocalVolumeOverride, '
'inWindow=$isWithinWindow (${timeSinceLastDrag}ms ago), '
'localVol=${(_dragVolumeLevel * 100).round()}%, '
'playerVol=${(playerVolume * 100).round()}%, '
'startVol=${(startVolume * 100).round()}%',
context: 'Volume',
);
setState(() {
_isDraggingVolume = true;
_dragVolumeLevel = currentVolume;
_dragVolumeLevel = startVolume;
});
_lastDragPosition = details.globalPosition;
HapticFeedback.lightImpact();
}
void _onDragUpdate(DragUpdateDetails details) {
if (!_isDraggingVolume || _cardWidth <= 0) return;
// Directional volume: drag right = increase, drag left = decrease
// Distance determines amount of change (full card width = 100%)
// Multiple swipes accumulate - each starts from current volume
// Track local X position for precision mode
_lastLocalX = details.localPosition.dx;
// Check for stillness to trigger precision mode (only if enabled in settings)
final currentPosition = details.globalPosition;
if (_precisionModeEnabled && _lastDragPosition != null) {
final movement = (currentPosition - _lastDragPosition!).distance;
if (movement < PlayerCard.precisionStillnessThreshold) {
// Finger is still - start precision timer if not already running
if (_precisionTimer == null && !_inPrecisionMode) {
_precisionTimer = Timer(
Duration(milliseconds: PlayerCard.precisionTriggerMs),
_enterPrecisionMode,
);
}
} else {
// Finger moved - cancel timer (but don't exit precision mode if already in it)
_precisionTimer?.cancel();
_precisionTimer = null;
}
}
_lastDragPosition = currentPosition;
double newVolume;
if (_inPrecisionMode) {
// PRECISION MODE: Movement from entry point maps to zoomed range
// Full card width of movement = precisionSensitivity (10%) change
// e.g., at 40% center: moving full card right = 50%, full card left = 30%
final offsetX = details.localPosition.dx - _precisionStartX;
final normalizedOffset = offsetX / _cardWidth; // -1.0 to +1.0 range
final volumeChange = normalizedOffset * PlayerCard.precisionSensitivity;
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
} else {
// NORMAL MODE: Delta-based movement (full card width = 100%)
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / _cardWidth;
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
}
// Always update visual
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
@@ -308,9 +442,10 @@ class _PlayerCardState extends State<PlayerCard> {
_dragVolumeLevel = newVolume;
});
// Throttle API calls to prevent flooding
// Throttle API calls to prevent flooding (faster in precision mode)
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastVolumeUpdateTime >= _volumeThrottleMs) {
final throttleMs = _inPrecisionMode ? _precisionThrottleMs : _volumeThrottleMs;
if (now - _lastVolumeUpdateTime >= throttleMs) {
_lastVolumeUpdateTime = now;
widget.onVolumeChange?.call(newVolume);
}
@@ -319,7 +454,17 @@ class _PlayerCardState extends State<PlayerCard> {
void _onDragEnd(DragEndDetails details) {
// Send final volume on release
_volumeLogger.debug(
'DRAG_END [${widget.player.name}]: '
'finalVol=${(_dragVolumeLevel * 100).round()}%, '
'precisionMode=$_inPrecisionMode',
context: 'Volume',
);
widget.onVolumeChange?.call(_dragVolumeLevel);
_lastDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
_exitPrecisionMode();
_lastDragPosition = null;
setState(() {
_isDraggingVolume = false;
});
@@ -327,6 +472,8 @@ class _PlayerCardState extends State<PlayerCard> {
}
void _onDragCancel() {
_exitPrecisionMode();
_lastDragPosition = null;
setState(() {
_isDraggingVolume = false;
});
+97 -73
View File
@@ -54,6 +54,13 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Hint system
bool _showHints = true;
// PERF: Pre-cached static BoxShadow to avoid allocation per frame
static const BoxShadow _cardShadow = BoxShadow(
color: Color(0x33000000), // 20% black
blurRadius: 8,
offset: Offset(0, 2),
);
@override
void initState() {
super.initState();
@@ -231,6 +238,9 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// PERF Phase 4: Pre-compute hint colors outside animation loop
final hintColor = colorScheme.onSurface.withOpacity(0.7);
// Back gesture is handled at GlobalPlayerOverlay level
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
@@ -267,6 +277,31 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
}
});
// PERF Phase 4: Pre-compute player card data OUTSIDE AnimatedBuilder
// This avoids re-gathering player data every animation frame
final playerDataList = players.map((player) {
final isPlaying = player.state == 'playing';
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
final isGrouped = maProvider.isPlayerManuallySynced(player.playerId);
return _PlayerCardData(
player: player,
playerTrack: playerTrack,
albumArtUrl: albumArtUrl,
isPlaying: isPlaying,
isGrouped: isGrouped,
cardBgColor: cardBgColor,
cardTextColor: cardTextColor,
);
}).toList();
return AnimatedBuilder(
animation: _revealAnimation,
builder: (context, child) {
@@ -308,6 +343,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Show different hint for onboarding vs regular use
final isOnboarding = widget.showOnboardingHints;
// PERF Phase 4: Use pre-computed hintColor
Widget buildHintRow(IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
@@ -315,13 +351,13 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
Icon(
icon,
size: 18,
color: colorScheme.onSurface.withOpacity(0.7),
color: hintColor,
),
const SizedBox(width: 6),
Text(
text,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
color: hintColor,
fontSize: 16,
fontWeight: FontWeight.w500,
),
@@ -354,31 +390,23 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Build player cards - scrollable when overflow, otherwise static
// Using ListView.builder for many players: only builds visible items
// This significantly improves animation performance with 10+ players
// PERF Phase 4: Use pre-computed playerDataList
if (needsScroll)
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxListHeight),
child: ListView.builder(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
itemCount: players.length,
itemCount: playerDataList.length,
// Fixed height per item for optimal scroll performance
itemExtent: cardHeight + cardSpacing,
itemBuilder: (context, index) {
final player = players[index];
final isPlaying = player.state == 'playing';
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
final data = playerDataList[index];
// Animation: slide from behind mini player
// Only calculate for items that will be built (visible ones)
const baseOffset = 80.0;
final reverseIndex = players.length - 1 - index;
final reverseIndex = playerDataList.length - 1 - index;
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
final slideOffset = distanceToTravel * (1.0 - t);
@@ -387,33 +415,33 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
child: Padding(
padding: const EdgeInsets.only(bottom: cardSpacing),
child: PlayerCard(
player: player,
trackInfo: playerTrack,
albumArtUrl: albumArtUrl,
player: data.player,
trackInfo: data.playerTrack,
albumArtUrl: data.albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
backgroundColor: cardBgColor,
textColor: cardTextColor,
isPlaying: data.isPlaying,
isGrouped: data.isGrouped,
backgroundColor: data.cardBgColor,
textColor: data.cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(player);
maProvider.selectPlayer(data.player);
dismiss();
},
onLongPress: () {
HapticFeedback.mediumImpact();
maProvider.togglePlayerSync(player.playerId);
maProvider.togglePlayerSync(data.player.playerId);
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
if (data.isPlaying) {
maProvider.pausePlayer(data.player.playerId);
} else {
maProvider.resumePlayer(player.playerId);
maProvider.resumePlayer(data.player.playerId);
}
},
onSkipNext: () => maProvider.nextTrack(player.playerId),
onPower: () => maProvider.togglePower(player.playerId),
onVolumeChange: (volume) => maProvider.setVolume(player.playerId, (volume * 100).round()),
onSkipNext: () => maProvider.nextTrack(data.player.playerId),
onPower: () => maProvider.togglePower(data.player.playerId),
onVolumeChange: (volume) => maProvider.setVolume(data.player.playerId, (volume * 100).round()),
),
),
);
@@ -422,28 +450,14 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
)
else
// Non-scrollable list for when players fit
...List.generate(players.length, (index) {
final player = players[index];
final isPlaying = player.state == 'playing';
// Get track info for this player
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
// Get album art URL - use track directly like mini player does
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
// Use per-player colors if available, otherwise use defaults
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
// PERF Phase 4: Use pre-computed playerDataList
...List.generate(playerDataList.length, (index) {
final data = playerDataList[index];
// Animation: all cards start hidden behind mini player
// and fan out to their final positions with spring physics
const baseOffset = 80.0;
final reverseIndex = players.length - 1 - index;
final reverseIndex = playerDataList.length - 1 - index;
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
// Use the elastic animation value directly (already has bounce)
@@ -454,44 +468,33 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PlayerCard(
player: player,
trackInfo: playerTrack,
albumArtUrl: albumArtUrl,
player: data.player,
trackInfo: data.playerTrack,
albumArtUrl: data.albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
backgroundColor: cardBgColor,
textColor: cardTextColor,
isPlaying: data.isPlaying,
isGrouped: data.isGrouped,
backgroundColor: data.cardBgColor,
textColor: data.cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(player);
maProvider.selectPlayer(data.player);
dismiss();
},
onLongPress: () {
HapticFeedback.mediumImpact();
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Syncing ${player.name}...'),
duration: const Duration(seconds: 2),
),
);
maProvider.togglePlayerSync(player.playerId);
maProvider.togglePlayerSync(data.player.playerId);
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
if (data.isPlaying) {
maProvider.pausePlayer(data.player.playerId);
} else {
maProvider.resumePlayer(player.playerId);
maProvider.resumePlayer(data.player.playerId);
}
},
onSkipNext: () {
maProvider.nextTrack(player.playerId);
},
onPower: () {
maProvider.togglePower(player.playerId);
},
onVolumeChange: (volume) => maProvider.setVolume(player.playerId, (volume * 100).round()),
onSkipNext: () => maProvider.nextTrack(data.player.playerId),
onPower: () => maProvider.togglePower(data.player.playerId),
onVolumeChange: (volume) => maProvider.setVolume(data.player.playerId, (volume * 100).round()),
),
),
);
@@ -508,3 +511,24 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
);
}
}
/// PERF Phase 4: Pre-computed player card data to avoid gathering per animation frame
class _PlayerCardData {
final dynamic player;
final dynamic playerTrack;
final String? albumArtUrl;
final bool isPlaying;
final bool isGrouped;
final Color cardBgColor;
final Color cardTextColor;
const _PlayerCardData({
required this.player,
required this.playerTrack,
required this.albumArtUrl,
required this.isPlaying,
required this.isGrouped,
required this.cardBgColor,
required this.cardTextColor,
});
}
+649 -50
View File
@@ -1,12 +1,19 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart';
import '../../models/player.dart';
import '../../theme/design_tokens.dart';
import '../common/empty_state.dart';
/// Panel that displays the current playback queue
class QueuePanel extends StatelessWidget {
/// Panel that displays the current playback queue with drag-to-reorder
/// and swipe-left-to-delete functionality.
///
/// Uses custom AnimatedList implementation instead of great_list_view
/// to avoid grey screen bugs (ReorderableListView) and drag duplication bugs (great_list_view).
class QueuePanel extends StatefulWidget {
final MusicAssistantProvider maProvider;
final PlayerQueue? queue;
final bool isLoading;
@@ -16,6 +23,10 @@ class QueuePanel extends StatelessWidget {
final double topPadding;
final VoidCallback onClose;
final VoidCallback onRefresh;
final ValueChanged<bool>? onDraggingChanged;
final VoidCallback? onSwipeStart;
final ValueChanged<double>? onSwipeUpdate; // dx delta from start
final void Function(double velocity, double totalDx)? onSwipeEnd; // velocity and total displacement
const QueuePanel({
super.key,
@@ -28,38 +39,501 @@ class QueuePanel extends StatelessWidget {
required this.topPadding,
required this.onClose,
required this.onRefresh,
this.onDraggingChanged,
this.onSwipeStart,
this.onSwipeUpdate,
this.onSwipeEnd,
});
@override
State<QueuePanel> createState() => _QueuePanelState();
}
class _QueuePanelState extends State<QueuePanel> {
List<QueueItem> _items = [];
final GlobalKey _stackKey = GlobalKey();
// Drag state
int? _dragIndex;
int? _dragStartIndex;
double _dragY = 0; // Y position of dragged item relative to Stack
double _dragStartY = 0; // Y position when drag started (global)
double _dragOffsetInItem = 0; // Offset of touch point within item
QueueItem? _dragItem;
double _itemHeight = 64.0;
bool _pendingReorder = false; // True while waiting for API confirmation
Timer? _pendingReorderTimer;
// Optimistic update for tap-to-skip
int? _optimisticCurrentIndex;
// Swipe-to-close tracking (raw pointer events to bypass gesture arena)
Offset? _swipeStart;
Offset? _swipeLast;
int? _swipeLastTime; // milliseconds since epoch
bool _isSwiping = false;
bool _swipeLocked = false; // Lock direction once established
static const _swipeMinDistance = 8.0; // Min distance to start tracking (reduced for responsiveness)
static const _edgeDeadZone = 48.0; // Dead zone for Android back gesture (increased)
// Track last touch start position for edge detection in Dismissible
// Static so it persists across widget rebuilds
static double? _lastPointerDownX;
static double? _lastScreenWidth;
// Velocity tracking with multiple samples for smoother calculation
final List<_VelocitySample> _velocitySamples = [];
static const _maxVelocitySamples = 5;
@override
void initState() {
super.initState();
_items = List.from(widget.queue?.items ?? []);
}
@override
void didUpdateWidget(QueuePanel oldWidget) {
super.didUpdateWidget(oldWidget);
// Clear optimistic state when server catches up
if (_optimisticCurrentIndex != null) {
final serverIndex = widget.queue?.currentIndex;
if (serverIndex == _optimisticCurrentIndex) {
// Server caught up - clear optimistic state
_optimisticCurrentIndex = null;
}
}
// Don't update items while dragging or waiting for reorder confirmation
if (_dragIndex != null || _pendingReorder) return;
final newItems = widget.queue?.items ?? [];
// Only sync when player changes or item count changes (additions/deletions)
// Don't sync on order changes - trust our local order after user reorders
// This prevents stale server state from overwriting recent local changes
final playerChanged = widget.queue?.playerId != oldWidget.queue?.playerId;
final countChanged = newItems.length != _items.length;
if (playerChanged || countChanged) {
setState(() {
_items = List.from(newItems);
});
}
}
@override
void dispose() {
_pendingReorderTimer?.cancel();
super.dispose();
}
String _formatDuration(Duration? duration) {
if (duration == null) return '';
final totalSeconds = duration.inSeconds;
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
void _handleDelete(QueueItem item, int index) async {
// Store item for potential rollback
final deletedItem = item;
final deletedIndex = index;
// Optimistic update - remove from local list
setState(() {
_items.removeAt(index);
});
// Call API with error handling
final playerId = widget.queue?.playerId;
if (playerId != null) {
try {
await widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId);
debugPrint('QueuePanel: Delete successful for ${item.track.name}');
} catch (e) {
debugPrint('QueuePanel: Error deleting queue item: $e');
// Rollback: re-insert the item or refresh from server
// Refresh is safer as queue may have changed
widget.onRefresh();
}
}
}
void _handleClearQueue() async {
final playerId = widget.queue?.playerId;
if (playerId == null) return;
// Optimistic update - clear local list
setState(() {
_items.clear();
});
// Call API
try {
await widget.maProvider.api?.queueCommandClear(playerId);
} catch (e) {
debugPrint('QueuePanel: Error clearing queue: $e');
// Refresh to restore if failed
widget.onRefresh();
}
}
void _resetSwipeState() {
_swipeStart = null;
_swipeLast = null;
_swipeLastTime = null;
_isSwiping = false;
_swipeLocked = false;
_velocitySamples.clear();
// NOTE: Don't reset _lastPointerDownX - it's needed by confirmDismiss
// which may be called after pointer up
}
/// Check if x position is in the edge dead zone (Android back gesture area)
static bool _isInEdgeZone(double x, double screenWidth) {
return x < _edgeDeadZone || x > screenWidth - _edgeDeadZone;
}
/// Check if last touch started in edge zone (for Dismissible to check)
static bool get lastTouchWasInEdgeZone {
if (_lastPointerDownX == null || _lastScreenWidth == null) return false;
return _isInEdgeZone(_lastPointerDownX!, _lastScreenWidth!);
}
/// Build an invisible edge absorber that blocks horizontal drags from triggering Dismissible
Widget _buildEdgeAbsorber({required bool left}) {
return Positioned(
left: left ? 0 : null,
right: left ? null : 0,
top: 0,
bottom: 0,
width: _edgeDeadZone,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (_) {}, // Absorb horizontal drags
onHorizontalDragUpdate: (_) {},
onHorizontalDragEnd: (_) {},
),
);
}
void _addVelocitySample(Offset position, int timeMs) {
_velocitySamples.add(_VelocitySample(position, timeMs));
if (_velocitySamples.length > _maxVelocitySamples) {
_velocitySamples.removeAt(0);
}
}
double _calculateAverageVelocity() {
if (_velocitySamples.length < 2) return 0.0;
// Use weighted average of recent samples (more recent = higher weight)
double totalVelocity = 0.0;
double totalWeight = 0.0;
for (int i = 1; i < _velocitySamples.length; i++) {
final prev = _velocitySamples[i - 1];
final curr = _velocitySamples[i];
final dt = curr.timeMs - prev.timeMs;
if (dt > 0 && dt < 100) { // Only use samples within 100ms
final dx = curr.position.dx - prev.position.dx;
final velocity = (dx / dt) * 1000; // px/s
final weight = i.toDouble(); // Later samples get higher weight
totalVelocity += velocity * weight;
totalWeight += weight;
}
}
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
}
void _startDrag(int index, BuildContext itemContext, Offset globalPosition) {
if (_dragIndex != null) return;
// Clear any pending swipe state to prevent conflicts
_resetSwipeState();
final RenderBox itemBox = itemContext.findRenderObject() as RenderBox;
final Offset itemGlobalPos = itemBox.localToGlobal(Offset.zero);
_itemHeight = itemBox.size.height;
// Get Stack's global position for proper coordinate conversion
final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?;
final stackGlobalPos = stackBox?.localToGlobal(Offset.zero) ?? Offset.zero;
// Calculate initial position relative to Stack
_dragY = itemGlobalPos.dy - stackGlobalPos.dy;
_dragStartY = globalPosition.dy;
// Remember where in the item the touch started (for smooth following)
_dragOffsetInItem = globalPosition.dy - itemGlobalPos.dy;
setState(() {
_dragIndex = index;
_dragStartIndex = index;
_dragItem = _items[index];
});
// Haptic feedback on drag start
HapticFeedback.mediumImpact();
// Notify parent that drag started
widget.onDraggingChanged?.call(true);
}
void _updateDragPointer(Offset globalPosition) {
if (_dragIndex == null) return;
// Get Stack's global position
final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?;
final stackGlobalPos = stackBox?.localToGlobal(Offset.zero) ?? Offset.zero;
// Calculate overlay position relative to Stack, accounting for touch offset
_dragY = globalPosition.dy - stackGlobalPos.dy - _dragOffsetInItem;
// Calculate which index we're hovering over based on movement from start
final totalOffset = globalPosition.dy - _dragStartY;
final indexOffset = (totalOffset / _itemHeight).round();
final targetIndex = (_dragStartIndex! + indexOffset).clamp(0, _items.length - 1);
if (targetIndex != _dragIndex) {
// Reorder items in the list
setState(() {
final item = _items.removeAt(_dragIndex!);
_items.insert(targetIndex, item);
_dragIndex = targetIndex;
});
// Haptic feedback on each reorder
HapticFeedback.selectionClick();
} else {
// Just update position
setState(() {});
}
}
void _endDrag() async {
if (_dragIndex == null || _dragStartIndex == null) return;
final newIndex = _dragIndex!;
final originalIndex = _dragStartIndex!;
final item = _items[newIndex];
final positionChanged = originalIndex != newIndex;
// Cancel any existing timer to prevent stacking
_pendingReorderTimer?.cancel();
setState(() {
_dragIndex = null;
_dragStartIndex = null;
_dragItem = null;
// Block didUpdateWidget while waiting for server confirmation
if (positionChanged) _pendingReorder = true;
});
// Haptic feedback on drop
HapticFeedback.lightImpact();
// Notify parent that drag ended
widget.onDraggingChanged?.call(false);
// Call API if position changed
if (positionChanged) {
final playerId = widget.queue?.playerId;
// API uses relative shift: positive = down, negative = up
final posShift = newIndex - originalIndex;
debugPrint('QueuePanel: Moving ${item.track.name} from $originalIndex to $newIndex (shift: $posShift, queueItemId: ${item.queueItemId})');
if (playerId != null) {
try {
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift);
debugPrint('QueuePanel: Move API call completed successfully');
// Allow updates again after a delay for server state to propagate
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
if (mounted) {
setState(() {
_pendingReorder = false;
});
}
});
} catch (e) {
debugPrint('QueuePanel: Move API error: $e');
// Clear pending state immediately on error
if (mounted) {
setState(() {
_pendingReorder = false;
});
}
// Refresh queue from server to get correct state
widget.onRefresh();
}
} else {
debugPrint('QueuePanel: playerId is null, cannot move');
// Clear pending state
if (mounted) {
setState(() {
_pendingReorder = false;
});
}
}
}
}
void _cancelDrag() {
if (_dragStartIndex == null) return;
// Restore original position
if (_dragIndex != null && _dragIndex != _dragStartIndex) {
setState(() {
final item = _items.removeAt(_dragIndex!);
_items.insert(_dragStartIndex!, item);
});
}
setState(() {
_dragIndex = null;
_dragStartIndex = null;
_dragItem = null;
});
// Notify parent that drag ended
widget.onDraggingChanged?.call(false);
}
void _handleTapToSkip(QueueItem item, int index, bool isCurrentItem) async {
if (isCurrentItem) return;
final playerId = widget.queue?.playerId;
if (playerId == null) return;
// Optimistic update: immediately show tapped track as current
setState(() {
_optimisticCurrentIndex = index;
});
// Call API
try {
await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId);
} catch (e) {
debugPrint('QueuePanel: Error playing index: $e');
}
// Clear optimistic state after delay - server state will take over
await Future.delayed(const Duration(milliseconds: 1500));
if (mounted) {
setState(() {
_optimisticCurrentIndex = null;
});
}
}
@override
Widget build(BuildContext context) {
return Container(
color: backgroundColor,
// Use Listener for raw pointer events to bypass gesture arena
// This allows swipe-to-close to work even over ListView/Dismissible
return Listener(
onPointerDown: (event) {
// Store touch position for edge detection (used by Dismissible confirmDismiss)
// Use static variables so they persist across rebuilds
_lastPointerDownX = event.position.dx;
_lastScreenWidth = MediaQuery.of(context).size.width;
// Check if touch started in edge zone (Android back gesture area)
final startedInEdgeZone = _isInEdgeZone(event.position.dx, _lastScreenWidth!);
// Don't track swipe while dragging a queue item
if (_dragIndex == null && !startedInEdgeZone) {
_swipeStart = event.position;
_swipeLast = event.position;
_swipeLastTime = DateTime.now().millisecondsSinceEpoch;
_isSwiping = false;
_swipeLocked = false;
_velocitySamples.clear();
_addVelocitySample(event.position, _swipeLastTime!);
} else if (startedInEdgeZone) {
// Clear swipe state for edge touches
_swipeStart = null;
}
},
onPointerMove: (event) {
// Ignore swipes from edge zone (let Android back gesture handle it)
if (_swipeStart == null || _dragIndex != null) return;
final dx = event.position.dx - _swipeStart!.dx;
final dy = (event.position.dy - _swipeStart!.dy).abs();
final now = DateTime.now().millisecondsSinceEpoch;
// Track all move events for velocity calculation
_addVelocitySample(event.position, now);
_swipeLast = event.position;
_swipeLastTime = now;
// Once direction is locked, maintain it (prevents accidental cancellation)
if (_swipeLocked && _isSwiping) {
widget.onSwipeUpdate?.call(dx.clamp(0.0, double.infinity));
return;
}
// Check if this is a horizontal swipe (reduced tolerance: dx > dy * 1.2)
final isHorizontal = dx.abs() > _swipeMinDistance && dx.abs() > dy * 1.2;
if (isHorizontal && dx > 0) {
// Horizontal swipe right - close gesture
if (!_isSwiping) {
_isSwiping = true;
_swipeLocked = true; // Lock direction once swipe starts
widget.onSwipeStart?.call();
}
widget.onSwipeUpdate?.call(dx);
} else if (!_swipeLocked && dx.abs() > _swipeMinDistance) {
// Direction established as vertical - don't start swipe
_swipeLocked = true; // Lock as non-swipe
}
},
onPointerUp: (event) {
if (_isSwiping && _swipeStart != null) {
final velocity = _calculateAverageVelocity();
final totalDx = event.position.dx - _swipeStart!.dx;
widget.onSwipeEnd?.call(velocity, totalDx);
}
_resetSwipeState();
},
onPointerCancel: (_) {
if (_isSwiping) {
widget.onSwipeEnd?.call(0, 0); // Cancel - no action
}
_resetSwipeState();
},
child: Container(
color: widget.backgroundColor,
child: Column(
children: [
// Header
Container(
padding: EdgeInsets.only(top: topPadding + 4, left: 4, right: 16),
padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back_rounded, color: textColor, size: IconSizes.md),
onPressed: onClose,
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md),
onPressed: widget.onClose,
padding: Spacing.paddingAll12,
),
const Spacer(),
Text(
'Queue',
style: TextStyle(
color: textColor,
color: widget.textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
// Clear queue button
IconButton(
icon: Icon(Icons.refresh_rounded, color: textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: onRefresh,
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: _handleClearQueue,
padding: Spacing.paddingAll12,
tooltip: 'Clear queue',
),
],
),
@@ -67,48 +541,162 @@ class QueuePanel extends StatelessWidget {
// Queue content
Expanded(
child: isLoading
? Center(child: CircularProgressIndicator(color: primaryColor))
: queue == null || queue!.items.isEmpty
? _buildEmptyState(context)
: _buildQueueList(),
child: widget.isLoading
? Center(child: CircularProgressIndicator(color: widget.primaryColor))
: widget.queue == null || _items.isEmpty
? EmptyState.queue(context: context)
: Listener(
// Capture pointer events anywhere while dragging
onPointerMove: (event) {
if (_dragIndex != null) {
_updateDragPointer(event.position);
}
},
onPointerUp: (event) {
if (_dragIndex != null) {
_endDrag();
}
},
onPointerCancel: (event) {
if (_dragIndex != null) {
_cancelDrag();
}
},
child: Stack(
key: _stackKey,
children: [
_buildQueueList(),
// Dragged item overlay
if (_dragIndex != null && _dragItem != null)
Positioned(
left: 8,
right: 8,
top: _dragY,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
child: _buildQueueItemContent(_dragItem!, _dragIndex!, false, false),
),
),
// Edge gesture absorbers - block horizontal drags from screen edges
// This prevents Android back gesture from triggering Dismissible
_buildEdgeAbsorber(left: true),
_buildEdgeAbsorber(left: false),
],
),
),
),
],
),
),
);
}
Widget _buildEmptyState(BuildContext context) {
return EmptyState.queue(context: context);
}
Widget _buildQueueList() {
final currentIndex = queue!.currentIndex ?? 0;
final items = queue!.items;
// Use optimistic index if set, otherwise use server state
final currentIndex = _optimisticCurrentIndex ?? widget.queue!.currentIndex ?? 0;
return ListView.builder(
key: const PageStorageKey('queue_list'),
padding: Spacing.paddingH8,
cacheExtent: 500,
addAutomaticKeepAlives: false, // Items don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually
itemCount: items.length,
// Disable scrolling while dragging to prevent gesture conflict
physics: _dragIndex != null
? const NeverScrollableScrollPhysics()
: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length,
itemBuilder: (context, index) {
final item = items[index];
final item = _items[index];
final isCurrentItem = index == currentIndex;
final isPastItem = index < currentIndex;
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
final isDragging = _dragIndex == index;
return Opacity(
opacity: isDragging ? 0.3 : 1.0,
child: _buildDismissibleItem(item, index, isCurrentItem, isPastItem),
);
},
);
}
Widget _buildDismissibleItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem) {
return Dismissible(
key: ValueKey(item.queueItemId),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: Colors.red.shade700,
borderRadius: BorderRadius.zero,
),
child: const Icon(Icons.delete_outline, color: Colors.white, size: 24),
),
confirmDismiss: (direction) async {
// Block dismissal if swipe started from screen edge (Android back gesture)
if (lastTouchWasInEdgeZone) return false;
// Don't allow dismissing the currently playing item
return !isCurrentItem;
},
onDismissed: (direction) => _handleDelete(item, index),
child: _buildQueueItemWithDragHandle(item, index, isCurrentItem, isPastItem),
);
}
Widget _buildQueueItemWithDragHandle(QueueItem item, int index, bool isCurrentItem, bool isPastItem) {
return Builder(
builder: (itemContext) => _buildQueueItemContent(
item,
index,
isCurrentItem,
isPastItem,
dragHandle: isCurrentItem
? SizedBox(
width: 48,
height: 48,
child: Center(
child: Icon(Icons.play_arrow_rounded, color: widget.primaryColor, size: 20),
),
)
: SizedBox(
width: 48,
height: 48,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (event) {
_startDrag(index, itemContext, event.position);
},
// Move/up/cancel handled by parent Listener on Stack
child: Center(
child: Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20),
),
),
),
),
);
}
Widget _buildQueueItemContent(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {Widget? dragHandle}) {
final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80);
final duration = _formatDuration(item.track.duration);
return RepaintBoundary(
child: Opacity(
opacity: isPastItem ? 0.5 : 1.0,
child: Container(
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
margin: isCurrentItem ? const EdgeInsets.symmetric(horizontal: 8, vertical: 2) : EdgeInsets.zero,
decoration: BoxDecoration(
color: isCurrentItem ? primaryColor.withOpacity(0.15) : Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
color: isCurrentItem ? widget.primaryColor.withOpacity(0.15) : widget.backgroundColor,
borderRadius: isCurrentItem ? BorderRadius.circular(12) : BorderRadius.zero,
),
child: ListTile(
dense: true,
// Compensate for current item's 8px margin on both sides
// 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
contentPadding: EdgeInsets.only(
left: isCurrentItem ? 8 : 16,
right: isCurrentItem ? 0 : 8,
),
leading: ClipRRect(
borderRadius: BorderRadius.circular(Radii.sm),
child: SizedBox(
@@ -118,30 +706,29 @@ class QueuePanel extends StatelessWidget {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// PERF: 4x cache size for 44x44 display, zero fade for scrolling
memCacheWidth: 176,
memCacheHeight: 176,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
color: widget.textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
),
errorWidget: (context, url, error) => Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
color: widget.textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
),
)
: Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
color: widget.textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
),
),
),
title: Text(
item.track.name,
style: TextStyle(
color: isCurrentItem ? primaryColor : textColor,
color: isCurrentItem ? widget.primaryColor : widget.textColor,
fontSize: 14,
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
),
@@ -152,27 +739,39 @@ class QueuePanel extends StatelessWidget {
? Text(
item.track.artists!.first.name,
style: TextStyle(
color: textColor.withOpacity(0.6),
color: widget.textColor.withOpacity(0.6),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: isCurrentItem
? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20)
: null,
onTap: () {
// TODO: Jump to this track in queue
// Music Assistant API doesn't currently expose a skip_to_index or play_queue_item endpoint
// Would need to add API method like skipToQueueIndex(queueId, index) if MA supports it
// Alternative: Could use multiple next() calls but that's inefficient and unreliable
},
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (duration.isNotEmpty)
Text(
duration,
style: TextStyle(
color: widget.textColor.withOpacity(0.5),
fontSize: 12,
),
),
dragHandle ?? Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20),
],
),
onTap: () => _handleTapToSkip(item, index, isCurrentItem),
),
),
),
);
},
);
}
}
/// Helper class for velocity tracking samples
class _VelocitySample {
final Offset position;
final int timeMs;
_VelocitySample(this.position, this.timeMs);
}
+122
View File
@@ -0,0 +1,122 @@
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 '../screens/playlist_details_screen.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
class PlaylistCard extends StatelessWidget {
final Playlist playlist;
final VoidCallback? onTap;
final String? heroTagSuffix;
final int? imageCacheSize;
const PlaylistCard({
super.key,
required this.playlist,
this.onTap,
this.heroTagSuffix,
this.imageCacheSize,
});
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
final cacheSize = imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: onTap ?? () {
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: PlaylistDetailsScreen(
playlist: playlist,
heroTagSuffix: heroTagSuffix,
initialImageUrl: imageUrl,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Playlist artwork
AspectRatio(
aspectRatio: 1.0,
child: Hero(
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + suffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
Icons.playlist_play_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
Icons.playlist_play_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
const SizedBox(height: 8),
// Playlist title
Hero(
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
),
// Owner/track count
Text(
playlist.owner ?? (playlist.trackCount != null ? '${playlist.trackCount} tracks' : ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
);
}
}
+197
View File
@@ -0,0 +1,197 @@
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';
import 'playlist_card.dart';
class PlaylistRow extends StatefulWidget {
final String title;
final Future<List<Playlist>> Function() loadPlaylists;
final String? heroTagSuffix;
final double? rowHeight;
final List<Playlist>? Function()? getCachedPlaylists;
const PlaylistRow({
super.key,
required this.title,
required this.loadPlaylists,
this.heroTagSuffix,
this.rowHeight,
this.getCachedPlaylists,
});
@override
State<PlaylistRow> createState() => _PlaylistRowState();
}
class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClientMixin {
List<Playlist> _playlists = [];
bool _isLoading = true;
bool _hasLoaded = false;
static final _logger = DebugLogger();
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedPlaylists?.call();
if (cached != null && cached.isNotEmpty) {
_playlists = cached;
_isLoading = false;
}
_loadPlaylists();
}
Future<void> _loadPlaylists() async {
if (_hasLoaded) return;
_hasLoaded = true;
// Load fresh data
try {
final freshPlaylists = await widget.loadPlaylists();
if (mounted && freshPlaylists.isNotEmpty) {
setState(() {
_playlists = freshPlaylists;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precachePlaylistImages(freshPlaylists);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
void _precachePlaylistImages(List<Playlist> playlists) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
final playlistsToCache = playlists.take(10);
for (final playlist in playlistsToCache) {
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256);
if (imageUrl != null) {
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) => false);
}
}
}
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_playlists.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_playlists.isEmpty) {
return Center(
child: Text(
'No playlists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below (same as AlbumRow)
// Text area: 8px gap + ~18px title + ~18px owner = ~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: _playlists.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 playlist = _playlists[index];
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Container(
key: ValueKey(playlist.uri ?? playlist.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: PlaylistCard(
playlist: playlist,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('PlaylistRow:${widget.title}');
super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
// Total row height includes title + content (same as AlbumRow)
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
child: SizedBox(
height: totalHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: _buildContent(contentHeight, colorScheme),
),
],
),
),
);
_logger.endBuild('PlaylistRow:${widget.title}');
return result;
}
}
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}
+140
View File
@@ -0,0 +1,140 @@
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 '../screens/podcast_detail_screen.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
class PodcastCard extends StatelessWidget {
final MediaItem podcast;
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 PodcastCard({
super.key,
required this.podcast,
this.onTap,
this.heroTagSuffix,
this.imageCacheSize,
});
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
// Use provider's getPodcastImageUrl which includes iTunes cache
final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
// PERF: Use appropriate cache size based on display size
final cacheSize = imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: onTap ?? () {
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
FadeSlidePageRoute(
child: PodcastDetailScreen(
podcast: podcast,
heroTagSuffix: heroTagSuffix,
initialImageUrl: imageUrl,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Podcast artwork - square with rounded corners
AspectRatio(
aspectRatio: 1.0,
child: Hero(
tag: HeroTags.podcastCover + (podcast.uri ?? podcast.itemId) + suffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
const SizedBox(height: 8),
// Podcast title
Hero(
tag: HeroTags.podcastTitle + (podcast.uri ?? podcast.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
podcast.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
),
// Podcast author (if available)
Text(
_getAuthor(podcast),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
);
}
/// Extract author from podcast metadata
String _getAuthor(MediaItem podcast) {
final metadata = podcast.metadata;
if (metadata == null) return '';
// Try different metadata fields for author
final author = metadata['author'] ??
metadata['artist'] ??
metadata['owner'] ?? '';
return author.toString();
}
}
+205
View File
@@ -0,0 +1,205 @@
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';
import 'podcast_card.dart';
class PodcastRow extends StatefulWidget {
final String title;
final Future<List<MediaItem>> Function() loadPodcasts;
final String? heroTagSuffix;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<MediaItem>? Function()? getCachedPodcasts;
const PodcastRow({
super.key,
required this.title,
required this.loadPodcasts,
this.heroTagSuffix,
this.rowHeight,
this.getCachedPodcasts,
});
@override
State<PodcastRow> createState() => _PodcastRowState();
}
class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMixin {
List<MediaItem> _podcasts = [];
bool _isLoading = true;
bool _hasLoaded = false;
static final _logger = DebugLogger();
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedPodcasts?.call();
if (cached != null && cached.isNotEmpty) {
_podcasts = cached;
_isLoading = false;
}
_loadPodcasts();
}
Future<void> _loadPodcasts() async {
if (_hasLoaded) return;
_hasLoaded = true;
// Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshPodcasts = await widget.loadPodcasts();
if (mounted && freshPodcasts.isNotEmpty) {
setState(() {
_podcasts = freshPodcasts;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precachePodcastImages(freshPodcasts);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
/// Pre-cache podcast images so hero animations are smooth on first tap
void _precachePodcastImages(List<MediaItem> podcasts) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
// Only precache first ~10 visible items to avoid excessive network/memory use
final podcastsToCache = podcasts.take(10);
for (final podcast in podcastsToCache) {
// Use getPodcastImageUrl which includes iTunes cache
final imageUrl = maProvider.getPodcastImageUrl(podcast, size: 256);
if (imageUrl != null) {
// Use CachedNetworkImageProvider to warm the cache
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_podcasts.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_podcasts.isEmpty) {
return Center(
child: Text(
'No podcasts found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below (same as AlbumRow)
// Text area: 8px gap + ~18px title + ~18px author = ~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: _podcasts.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 podcast = _podcasts[index];
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Container(
key: ValueKey(podcast.uri ?? podcast.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: PodcastCard(
podcast: podcast,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('PodcastRow:${widget.title}');
super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
// Total row height includes title + content (same as AlbumRow)
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
child: SizedBox(
height: totalHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: _buildContent(contentHeight, colorScheme),
),
],
),
),
);
_logger.endBuild('PodcastRow:${widget.title}');
return result;
}
}
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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 '../constants/hero_tags.dart';
class RadioStationCard extends StatelessWidget {
final MediaItem radioStation;
final VoidCallback? onTap;
final String? heroTagSuffix;
final int? imageCacheSize;
const RadioStationCard({
super.key,
required this.radioStation,
this.onTap,
this.heroTagSuffix,
this.imageCacheSize,
});
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.api?.getImageUrl(radioStation, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
final cacheSize = imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: onTap ?? () {
HapticFeedback.mediumImpact();
// Play the radio station on selected player
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) {
maProvider.api?.playRadioStation(selectedPlayer.playerId, radioStation);
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Radio station artwork - circular for radio
AspectRatio(
aspectRatio: 1.0,
child: Hero(
tag: HeroTags.radioCover + (radioStation.uri ?? radioStation.itemId) + suffix,
child: ClipOval(
child: Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
Icons.radio_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
Icons.radio_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
const SizedBox(height: 8),
// Radio station name - fixed height container so image size is consistent
SizedBox(
height: 36, // Fixed height for 2 lines of text
child: Hero(
tag: HeroTags.radioTitle + (radioStation.uri ?? radioStation.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
radioStation.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.15,
),
),
),
),
),
],
),
),
);
}
}
+193
View File
@@ -0,0 +1,193 @@
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';
import 'radio_station_card.dart';
class RadioStationRow extends StatefulWidget {
final String title;
final Future<List<MediaItem>> Function() loadRadioStations;
final String? heroTagSuffix;
final double? rowHeight;
final List<MediaItem>? Function()? getCachedRadioStations;
const RadioStationRow({
super.key,
required this.title,
required this.loadRadioStations,
this.heroTagSuffix,
this.rowHeight,
this.getCachedRadioStations,
});
@override
State<RadioStationRow> createState() => _RadioStationRowState();
}
class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAliveClientMixin {
List<MediaItem> _radioStations = [];
bool _isLoading = true;
bool _hasLoaded = false;
static final _logger = DebugLogger();
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedRadioStations?.call();
if (cached != null && cached.isNotEmpty) {
_radioStations = cached;
_isLoading = false;
}
_loadRadioStations();
}
Future<void> _loadRadioStations() async {
if (_hasLoaded) return;
_hasLoaded = true;
// Load fresh data
try {
final freshStations = await widget.loadRadioStations();
if (mounted && freshStations.isNotEmpty) {
setState(() {
_radioStations = freshStations;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheRadioImages(freshStations);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
void _precacheRadioImages(List<MediaItem> stations) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
final stationsToCache = stations.take(10);
for (final station in stationsToCache) {
final imageUrl = maProvider.api?.getImageUrl(station, size: 256);
if (imageUrl != null) {
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) => false);
}
}
}
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_radioStations.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_radioStations.isEmpty) {
return Center(
child: Text(
'No radio stations found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: circle image + name below (same as ArtistRow)
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _radioStations.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 station = _radioStations[index];
return Container(
key: ValueKey(station.uri ?? station.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: RadioStationCard(
radioStation: station,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('RadioStationRow:${widget.title}');
super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
// Total row height includes title + content (same as ArtistRow)
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
child: SizedBox(
height: totalHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: _buildContent(contentHeight, colorScheme),
),
],
),
),
);
_logger.endBuild('RadioStationRow:${widget.title}');
return result;
}
}
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}
+111 -118
View File
@@ -12,12 +12,15 @@ class SeriesRow extends StatefulWidget {
final String title;
final Future<List<AudiobookSeries>> Function() loadSeries;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<AudiobookSeries>? Function()? getCachedSeries;
const SeriesRow({
super.key,
required this.title,
required this.loadSeries,
this.rowHeight,
this.getCachedSeries,
});
@override
@@ -25,8 +28,8 @@ class SeriesRow extends StatefulWidget {
}
class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixin {
late Future<List<AudiobookSeries>> _seriesFuture;
List<AudiobookSeries>? _cachedSeries;
List<AudiobookSeries> _series = [];
bool _isLoading = true;
bool _hasLoaded = false;
// Cache for series cover images
@@ -43,16 +46,34 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
@override
void initState() {
super.initState();
_loadSeriesOnce();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedSeries?.call();
if (cached != null && cached.isNotEmpty) {
_series = cached;
_isLoading = false;
}
_loadSeries();
}
void _loadSeriesOnce() {
if (!_hasLoaded) {
_seriesFuture = widget.loadSeries().then((series) {
_cachedSeries = series;
return series;
});
Future<void> _loadSeries() async {
if (_hasLoaded) return;
_hasLoaded = true;
// Load fresh data (always update)
try {
final freshSeries = await widget.loadSeries();
if (mounted && freshSeries.isNotEmpty) {
setState(() {
_series = freshSeries;
_isLoading = false;
});
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
@@ -78,8 +99,12 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
_seriesCovers[seriesId] = covers;
_seriesBookCounts[seriesId] = books.length;
});
// Extract colors asynchronously
_extractSeriesColors(seriesId, covers);
// Precache images for smooth hero animations
_precacheSeriesCovers(covers);
// Extract colors asynchronously (delayed to avoid jank during scroll)
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) _extractSeriesColors(seriesId, covers);
});
}
}
} finally {
@@ -87,6 +112,17 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
}
}
/// Precache series cover images for smooth hero animations
void _precacheSeriesCovers(List<String> covers) {
if (!mounted) return;
for (final url in covers) {
precacheImage(
CachedNetworkImageProvider(url),
context,
).catchError((_) => false);
}
}
/// Extract colors from series book covers for empty cell backgrounds
Future<void> _extractSeriesColors(String seriesId, List<String> covers) async {
if (_seriesExtractedColors.containsKey(seriesId) || covers.isEmpty) return;
@@ -127,10 +163,68 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme, TextTheme textTheme, MusicAssistantProvider maProvider) {
// Only show loading if we have no data at all
if (_series.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_series.isEmpty) {
return Center(
child: Text(
'No series found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _series.length,
itemExtent: itemExtent,
cacheExtent: 500,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemBuilder: (context, index) {
final s = _series[index];
// Load covers if not cached
if (!_seriesCovers.containsKey(s.id) && !_loadingCovers.contains(s.id)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadSeriesCovers(s.id, maProvider);
});
}
return Container(
key: ValueKey(s.id),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: _SeriesCard(
series: s,
covers: _seriesCovers[s.id],
cachedBookCount: _seriesBookCounts[s.id],
extractedColors: _seriesExtractedColors[s.id],
colorScheme: colorScheme,
textTheme: textTheme,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('SeriesRow:${widget.title}');
super.build(context);
super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final maProvider = context.read<MusicAssistantProvider>();
@@ -156,73 +250,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
),
),
Expanded(
child: FutureBuilder<List<AudiobookSeries>>(
future: _seriesFuture,
builder: (context, snapshot) {
final series = snapshot.data ?? _cachedSeries;
if (series == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && series == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (series == null || series.isEmpty) {
return Center(
child: Text(
'No series found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: series.length,
itemExtent: itemExtent,
cacheExtent: 500,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemBuilder: (context, index) {
final s = series[index];
// Load covers if not cached
if (!_seriesCovers.containsKey(s.id) && !_loadingCovers.contains(s.id)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadSeriesCovers(s.id, maProvider);
});
}
return Container(
key: ValueKey(s.id),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: _SeriesCard(
series: s,
covers: _seriesCovers[s.id],
cachedBookCount: _seriesBookCounts[s.id],
extractedColors: _seriesExtractedColors[s.id],
colorScheme: colorScheme,
textTheme: textTheme,
),
);
},
),
);
},
),
child: _buildContent(contentHeight, colorScheme, textTheme, maProvider),
),
],
),
@@ -286,52 +314,17 @@ class _SeriesCard extends StatelessWidget {
// Use AspectRatio to guarantee square cover
Hero(
tag: heroTag,
// PERF: Use flightShuttleBuilder to avoid animating complex grid
// during hero flight. Show cached first cover image instead.
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
// Use first cover as simple placeholder during flight
final heroChild = toContext.widget as Hero;
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return ClipRRect(
borderRadius: BorderRadius.circular(8 + 4 * animation.value),
child: child,
);
},
child: covers != null && covers!.isNotEmpty
? CachedNetworkImage(
imageUrl: covers!.first,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: colorScheme.surfaceContainerHighest,
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.collections_bookmark_rounded,
size: 48,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
),
);
},
// RepaintBoundary caches the rendered grid for smooth animation
child: RepaintBoundary(
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(12), // Match detail screen
child: _buildCoverGrid(),
),
),
),
),
const SizedBox(height: 8),
Text(
series.name,
+100 -68
View File
@@ -9,12 +9,15 @@ class TrackRow extends StatefulWidget {
final String title;
final Future<List<Track>> Function() loadTracks;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Track>? Function()? getCachedTracks;
const TrackRow({
super.key,
required this.title,
required this.loadTracks,
this.rowHeight,
this.getCachedTracks,
});
@override
@@ -22,8 +25,8 @@ class TrackRow extends StatefulWidget {
}
class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin {
late Future<List<Track>> _tracksFuture;
List<Track>? _cachedTracks;
List<Track> _tracks = [];
bool _isLoading = true;
bool _hasLoaded = false;
@override
@@ -32,25 +35,111 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
@override
void initState() {
super.initState();
_loadTracksOnce();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedTracks?.call();
if (cached != null && cached.isNotEmpty) {
_tracks = cached;
_isLoading = false;
}
_loadTracks();
}
void _loadTracksOnce() {
if (!_hasLoaded) {
_tracksFuture = widget.loadTracks().then((tracks) {
_cachedTracks = tracks;
return tracks;
});
Future<void> _loadTracks() async {
if (_hasLoaded) return;
_hasLoaded = true;
// Load fresh data (always update)
try {
final freshTracks = await widget.loadTracks();
if (mounted && freshTracks.isNotEmpty) {
setState(() {
_tracks = freshTracks;
_isLoading = false;
});
// Pre-cache images for smooth scrolling
_precacheTrackImages(freshTracks);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
void _precacheTrackImages(List<Track> tracks) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
final tracksToCache = tracks.take(10);
for (final track in tracksToCache) {
final imageUrl = maProvider.api?.getImageUrl(track, size: 256);
if (imageUrl != null) {
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) => false);
}
}
}
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_tracks.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_tracks.isEmpty) {
return Center(
child: Text(
'No tracks found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _tracks.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final track = _tracks[index];
return Container(
key: ValueKey(track.uri ?? track.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: _TrackCard(
track: track,
tracks: _tracks,
index: index,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('TrackRow:${widget.title}');
super.build(context);
super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
@@ -76,64 +165,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
),
),
Expanded(
child: FutureBuilder<List<Track>>(
future: _tracksFuture,
builder: (context, snapshot) {
final tracks = snapshot.data ?? _cachedTracks;
if (tracks == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && tracks == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (tracks == null || tracks.isEmpty) {
return Center(
child: Text(
'No tracks found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: tracks.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final track = tracks[index];
return Container(
key: ValueKey(track.uri ?? track.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: _TrackCard(
track: track,
tracks: tracks,
index: index,
),
);
},
),
);
},
),
child: _buildContent(contentHeight, colorScheme),
),
],
),
+339 -83
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import '../providers/music_assistant_provider.dart';
@@ -11,8 +12,9 @@ import '../services/settings_service.dart';
/// For MA players: controls player volume via API
class VolumeControl extends StatefulWidget {
final bool compact;
final Color? accentColor;
const VolumeControl({super.key, this.compact = false});
const VolumeControl({super.key, this.compact = false, this.accentColor});
@override
State<VolumeControl> createState() => _VolumeControlState();
@@ -25,11 +27,34 @@ class _VolumeControlState extends State<VolumeControl> {
StreamSubscription? _volumeListener;
String? _localPlayerId;
bool _isLocalPlayer = false;
bool _isDragging = false;
// Live volume update state
int _lastVolumeUpdateTime = 0;
static const int _volumeThrottleMs = 150;
static const int _precisionThrottleMs = 50;
// Precision mode state
bool _inPrecisionMode = false;
Timer? _precisionTimer;
Offset? _lastDragPosition;
double _lastLocalX = 0.0;
bool _precisionModeEnabled = true;
double _precisionZoomCenter = 0.0;
double _precisionStartX = 0.0;
static const int _precisionTriggerMs = 800;
static const double _precisionStillnessThreshold = 5.0;
static const double _precisionSensitivity = 0.1; // 10% range in precision mode
// Button tap indicator state
bool _showButtonIndicator = false;
Timer? _buttonIndicatorTimer;
@override
void initState() {
super.initState();
_initLocalPlayer();
_loadSettings();
}
Future<void> _initLocalPlayer() async {
@@ -43,7 +68,7 @@ class _VolumeControlState extends State<VolumeControl> {
}
_volumeListener = FlutterVolumeController.addListener((volume) {
if (mounted && _isLocalPlayer) {
if (mounted && _isLocalPlayer && !_isDragging) {
setState(() {
_systemVolume = volume;
});
@@ -51,12 +76,99 @@ class _VolumeControlState extends State<VolumeControl> {
});
}
Future<void> _loadSettings() async {
final precisionEnabled = await SettingsService.getVolumePrecisionMode();
if (mounted) {
setState(() {
_precisionModeEnabled = precisionEnabled;
});
}
}
void _enterPrecisionMode() {
if (_inPrecisionMode) return;
HapticFeedback.mediumImpact();
setState(() {
_inPrecisionMode = true;
_precisionZoomCenter = _pendingVolume ?? 0.5;
_precisionStartX = _lastLocalX;
});
}
void _exitPrecisionMode() {
_precisionTimer?.cancel();
_precisionTimer = null;
if (_inPrecisionMode) {
setState(() {
_inPrecisionMode = false;
});
}
}
@override
void dispose() {
_volumeListener?.cancel();
_precisionTimer?.cancel();
_buttonIndicatorTimer?.cancel();
super.dispose();
}
Future<void> _adjustVolume(MusicAssistantProvider maProvider, String playerId, double currentVolume, int delta) async {
final newVolume = ((currentVolume * 100).round() + delta).clamp(0, 100);
// Show indicator briefly on button tap
_buttonIndicatorTimer?.cancel();
setState(() {
_showButtonIndicator = true;
_pendingVolume = newVolume / 100.0;
});
_buttonIndicatorTimer = Timer(const Duration(milliseconds: 800), () {
if (mounted) {
setState(() {
_showButtonIndicator = false;
_pendingVolume = null;
});
}
});
if (_isLocalPlayer) {
_logger.log('Volume: Setting system volume to $newVolume%');
try {
await FlutterVolumeController.setVolume(newVolume / 100.0);
if (mounted) {
setState(() {
_systemVolume = newVolume / 100.0;
});
}
} catch (e) {
_logger.log('Volume: Error setting system volume - $e');
}
} else {
_logger.log('Volume: Setting to $newVolume%');
try {
await maProvider.setVolume(playerId, newVolume);
} catch (e) {
_logger.log('Volume: Error - $e');
}
}
}
void _sendVolumeUpdate(MusicAssistantProvider maProvider, String playerId, double volume) {
final now = DateTime.now().millisecondsSinceEpoch;
final throttleMs = _inPrecisionMode ? _precisionThrottleMs : _volumeThrottleMs;
if (now - _lastVolumeUpdateTime >= throttleMs) {
_lastVolumeUpdateTime = now;
final volumeLevel = (volume * 100).round();
if (_isLocalPlayer) {
FlutterVolumeController.setVolume(volume);
} else {
maProvider.setVolume(playerId, volumeLevel);
}
}
}
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
@@ -68,32 +180,20 @@ class _VolumeControlState extends State<VolumeControl> {
_isLocalPlayer = _localPlayerId != null && player.playerId == _localPlayerId;
final currentVolume = _isLocalPlayer
? (_pendingVolume ?? (_systemVolume ?? 0.5))
: (_pendingVolume ?? player.volume.toDouble()) / 100.0;
final isMuted = _isLocalPlayer ? false : player.isMuted;
// Use pending volume during drag or button tap to prevent stale state issues
final currentVolume = (_isDragging || _showButtonIndicator)
? (_pendingVolume ?? (_isLocalPlayer ? (_systemVolume ?? 0.5) : player.volume.toDouble() / 100.0))
: _isLocalPlayer
? (_systemVolume ?? 0.5)
: player.volume.toDouble() / 100.0;
// Use accent color if provided, otherwise fall back to theme primary
final accentColor = widget.accentColor ?? Theme.of(context).colorScheme.primary;
if (widget.compact) {
return IconButton(
icon: Icon(
isMuted ? Icons.volume_off : Icons.volume_up,
color: Colors.white70,
),
onPressed: () async {
try {
await maProvider.setMute(player.playerId, !isMuted);
} catch (e) {
// Error already logged by provider
}
},
);
}
return Row(
children: [
IconButton(
icon: Icon(
isMuted
currentVolume < 0.01
? Icons.volume_off
: currentVolume < 0.3
? Icons.volume_down
@@ -101,85 +201,241 @@ class _VolumeControlState extends State<VolumeControl> {
color: Colors.white70,
),
onPressed: () async {
try {
await maProvider.setMute(player.playerId, !isMuted);
} catch (e) {
// Error already logged by provider
}
// In compact mode, toggle between 0 and 50%
final newVolume = currentVolume < 0.01 ? 50 : 0;
await _adjustVolume(maProvider, player.playerId, newVolume / 100.0, 0);
},
);
}
return Row(
children: [
// Volume decrease button
IconButton(
icon: const Icon(
Icons.volume_down,
color: Colors.white70,
),
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, -1),
),
// Slider with floating teardrop indicator
Expanded(
child: SizedBox(
height: 48, // Increase touch target height
height: 48,
child: LayoutBuilder(
builder: (context, constraints) {
final sliderWidth = constraints.maxWidth;
// Calculate thumb position to match Flutter Slider's internal positioning
// The Slider uses overlayRadius as padding on each side for the track
const overlayRadius = 20.0; // Must match SliderThemeData overlayShape
final thumbPosition = overlayRadius +
(currentVolume.clamp(0.0, 1.0) * (sliderWidth - 2 * overlayRadius));
return Stack(
clipBehavior: Clip.none,
children: [
// The slider with gesture detection for precision mode
GestureDetector(
onHorizontalDragStart: (details) {
setState(() {
_isDragging = true;
_pendingVolume = currentVolume;
_lastDragPosition = details.globalPosition;
_lastLocalX = details.localPosition.dx;
});
},
onHorizontalDragUpdate: (details) {
if (!_isDragging) return;
final currentPosition = details.globalPosition;
// Check for stillness to trigger precision mode
if (_precisionModeEnabled && _lastDragPosition != null) {
final movement = (currentPosition - _lastDragPosition!).distance;
if (movement < _precisionStillnessThreshold) {
// Finger is still - start precision timer
if (_precisionTimer == null && !_inPrecisionMode) {
_precisionTimer = Timer(
Duration(milliseconds: _precisionTriggerMs),
_enterPrecisionMode,
);
}
} else {
// Finger moved - cancel timer
_precisionTimer?.cancel();
_precisionTimer = null;
}
}
_lastDragPosition = currentPosition;
_lastLocalX = details.localPosition.dx;
double newVolume;
if (_inPrecisionMode) {
// PRECISION MODE: Movement from entry point maps to zoomed range
final offsetX = details.localPosition.dx - _precisionStartX;
final normalizedOffset = offsetX / sliderWidth;
final volumeChange = normalizedOffset * _precisionSensitivity;
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
} else {
// NORMAL MODE: Position-based (like standard slider)
newVolume = (details.localPosition.dx / sliderWidth).clamp(0.0, 1.0);
}
if ((newVolume - (_pendingVolume ?? 0)).abs() > 0.001) {
setState(() {
_pendingVolume = newVolume;
});
_sendVolumeUpdate(maProvider, player.playerId, newVolume);
}
},
onHorizontalDragEnd: (details) {
if (!_isDragging) return;
// Send final volume
final finalVolume = _pendingVolume ?? currentVolume;
final volumeLevel = (finalVolume * 100).round();
if (_isLocalPlayer) {
FlutterVolumeController.setVolume(finalVolume);
_systemVolume = finalVolume;
} else {
maProvider.setVolume(player.playerId, volumeLevel);
}
_exitPrecisionMode();
_lastDragPosition = null;
setState(() {
_isDragging = false;
_pendingVolume = null;
});
},
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
activeTrackColor: Colors.white,
activeTrackColor: _inPrecisionMode ? accentColor : Colors.white,
inactiveTrackColor: Colors.white.withOpacity(0.3),
thumbColor: Colors.white,
overlayColor: Colors.white.withOpacity(0.2),
thumbColor: _inPrecisionMode ? accentColor : Colors.white,
overlayColor: (_inPrecisionMode ? accentColor : Colors.white).withOpacity(0.2),
),
child: AbsorbPointer(
// AbsorbPointer prevents Slider from handling gestures
// Our GestureDetector handles everything for live updates
child: Slider(
value: currentVolume.clamp(0.0, 1.0),
onChanged: (value) {
setState(() {
_pendingVolume = _isLocalPlayer ? value : value * 100;
});
},
onChangeEnd: (value) async {
if (_isLocalPlayer) {
_logger.log('Volume: Setting system volume to ${(value * 100).round()}%');
try {
await FlutterVolumeController.setVolume(value);
_systemVolume = value;
} catch (e) {
_logger.log('Volume: Error setting system volume - $e');
} finally {
if (mounted) {
setState(() {
_pendingVolume = null;
});
}
}
} else {
final volumeLevel = (value * 100).round();
_logger.log('Volume: Setting to $volumeLevel%');
try {
if (isMuted) {
_logger.log('Volume: Unmuting player first');
await maProvider.setMute(player.playerId, false);
}
await maProvider.setVolume(player.playerId, volumeLevel);
_logger.log('Volume: Set to $volumeLevel%');
} catch (e) {
_logger.log('Volume: Error - $e');
} finally {
if (mounted) {
setState(() {
_pendingVolume = null;
});
}
}
}
onChanged: (_) {},
),
),
),
),
// Floating teardrop indicator (visible when dragging or button tap)
if (_isDragging || _showButtonIndicator)
Positioned(
left: thumbPosition - 16,
top: -28,
child: CustomPaint(
size: const Size(32, 32),
painter: _TeardropPainter(
color: _inPrecisionMode ? accentColor : Colors.white,
volume: (currentVolume * 100).round(),
),
),
),
],
);
},
),
),
),
// Volume increase button
IconButton(
icon: const Icon(
Icons.volume_up,
color: Colors.white70,
),
SizedBox(
width: 40,
child: Text(
'${(currentVolume * 100).round()}%',
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
textAlign: TextAlign.center,
),
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, 1),
),
],
);
}
}
/// Custom painter for upside-down teardrop volume indicator
class _TeardropPainter extends CustomPainter {
final Color color;
final int volume;
_TeardropPainter({required this.color, required this.volume});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 2;
// Draw upside-down teardrop shape
final path = Path();
// Start at bottom point (the tip pointing down toward slider)
path.moveTo(center.dx, size.height - 2);
// Curve up to the left side of the circle
path.quadraticBezierTo(
center.dx - radius * 0.8,
center.dy + radius * 0.3,
center.dx - radius,
center.dy - radius * 0.2,
);
// Arc around the top (the bulb)
path.arcToPoint(
Offset(center.dx + radius, center.dy - radius * 0.2),
radius: Radius.circular(radius),
clockwise: true,
);
// Curve down to the bottom point
path.quadraticBezierTo(
center.dx + radius * 0.8,
center.dy + radius * 0.3,
center.dx,
size.height - 2,
);
path.close();
canvas.drawPath(path, paint);
// Draw the volume number (no % symbol)
final textColor = color.computeLuminance() > 0.5 ? Colors.black87 : Colors.white;
final textPainter = TextPainter(
text: TextSpan(
text: '$volume',
style: TextStyle(
color: textColor,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
// Position text in the center of the bulb (slightly above center)
final textOffset = Offset(
center.dx - textPainter.width / 2,
center.dy - radius * 0.4 - textPainter.height / 2,
);
textPainter.paint(canvas, textOffset);
}
@override
bool shouldRepaint(covariant _TeardropPainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.volume != volume;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.8.1-beta+38
version: 2.8.7-beta+45
environment:
sdk: '>=3.0.0 <4.0.0'