Compare commits

..
247 Commits
Author SHA1 Message Date
Your Name 6dbd0bbdfd Bump version to 2.8.6-beta+44 2026-01-09 21:38:25 +00:00
Your NameandClaude Opus 4.5 dedd1d2144 Fix podcast player: correct podcast name and text overlap
Two issues fixed:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This eliminates the visual jerk when expanding the player.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This eliminates the jank from mixed animation modes.

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

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

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

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

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

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

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

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

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

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

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

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

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

Album art and icons now align consistently across all items.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #31

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Nav bar should be more neutral than player card.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bump version to 2.8.4-beta+41

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

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

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

Bump version to 2.8.3-beta+40

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

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

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

Fixes #38

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also made the settings save async and added verification logging.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Now buttons visually update immediately when library status changes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Hero animations require IDENTICAL widget structures to morph smoothly.

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

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

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

FIXES APPLIED:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This should significantly improve tab swipe performance.

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

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

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

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

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

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

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

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

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

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

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

This significantly reduces widget rebuilds during swipe animations.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:48:11 +00:00
72 changed files with 12232 additions and 2222 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)
+119
View File
@@ -0,0 +1,119 @@
# Ensemble Codebase Audit Prompt
Run this prompt with Claude Code to perform a comprehensive audit of the Ensemble Flutter app.
---
## Prompt
You are performing a comprehensive code audit on the Ensemble Flutter app. This is a music/audiobook/podcast player app with 93+ Dart files.
**CRITICAL RULES:**
1. DO NOT remove or break any existing functionality
2. All changes must be on the current branch: `audit/code-quality-review`
3. Run `flutter analyze` after each set of changes to verify no regressions
4. Commit changes in logical, reviewable chunks
**AUDIT PHASES:**
### Phase 1: Code Quality Analysis (Read-Only)
First, analyze the codebase without making changes. Create a report covering:
1. **Large File Analysis** - Review these files for refactoring opportunities:
- `lib/screens/new_library_screen.dart` (122KB)
- `lib/screens/search_screen.dart` (129KB)
- `lib/widgets/expandable_player.dart` (107KB)
- `lib/screens/album_details_screen.dart` (48KB)
- `lib/screens/artist_details_screen.dart` (45KB)
2. **Pattern Inconsistencies** - Look for:
- Inconsistent naming conventions (camelCase vs snake_case)
- Mixed widget composition patterns
- Inconsistent error handling approaches
- Duplicate code across screens/widgets
3. **Performance Red Flags** - Identify:
- Unnecessary rebuilds (missing const constructors)
- Heavy computations in build methods
- Missing list item caching (ListView.builder vs ListView)
- Inefficient state management patterns
4. **Scroll Performance Issues** - Specifically audit:
- ScrollController usage and disposal
- Sliver implementations
- AnimatedBuilder/AnimatedWidget usage
- Image loading in scrollable lists
- Any jank-inducing patterns
### Phase 2: Quick Wins (Low Risk, High Impact)
Apply these fixes first:
1. Add missing `const` constructors throughout
2. Fix any `flutter analyze` warnings
3. Remove dead code and unused imports
4. Standardize formatting with `dart format`
### Phase 3: Scroll Animation Optimization
Focus on smoother scrolling:
1. **Image Optimization**
- Ensure CachedNetworkImage is used consistently
- Add appropriate cacheWidth/cacheHeight
- Implement proper placeholder/error widgets
2. **List Performance**
- Convert any ListView to ListView.builder where appropriate
- Add itemExtent where possible for fixed-height items
- Implement AutomaticKeepAliveClientMixin where needed
- Consider RepaintBoundary for complex list items
3. **Animation Smoothing**
- Use `Curves.easeOutCubic` or similar for scroll-linked animations
- Ensure animations run at 60fps (avoid heavy computations during animation)
- Review CustomScrollView/Sliver implementations
### Phase 4: Code Consistency Improvements
1. **Widget Extraction**
- Extract repeated UI patterns into reusable widgets
- Ensure extracted widgets follow existing naming patterns
2. **State Management**
- Verify Provider usage is consistent
- Check for proper disposal of controllers/streams
3. **Error Handling**
- Standardize try/catch patterns
- Ensure consistent loading/error state UI
### Phase 5: Verification
After all changes:
1. Run `flutter analyze` - must pass with 0 issues
2. Run `flutter test` if tests exist
3. Create a summary of all changes made
4. List any recommendations that were NOT implemented (too risky)
---
## Execution Instructions
```bash
# Start the audit
cd /home/home-server/Ensemble
git checkout audit/code-quality-review
# After each phase, commit your changes
git add -A && git commit -m "Audit Phase X: [description]"
# Verify no regressions
flutter analyze
```
---
## Expected Deliverables
1. `AUDIT_REPORT.md` - Findings from Phase 1
2. Multiple commits with clear messages for each phase
3. `AUDIT_SUMMARY.md` - Final summary of all changes and remaining recommendations
+136
View File
@@ -0,0 +1,136 @@
# Ensemble Flutter App - Code Audit Report
**Date:** January 4, 2026
**Branch:** `audit/code-quality-review`
**Files Audited:** 93 Dart files
---
## Executive Summary
The codebase is generally well-maintained with good existing performance optimizations (RepaintBoundary, ListView.builder, cacheExtent settings). Key improvement areas identified:
| Category | Issues Found | Priority |
|----------|-------------|----------|
| Deprecated APIs | 20 instances | HIGH |
| Missing const constructors | 100+ instances | MEDIUM |
| Image cache parameters | 7 locations | MEDIUM |
| Code duplication | 8 view mode functions | LOW |
| Color constants | 3 files with duplicates | LOW |
---
## 1. CRITICAL: Deprecated API Usage
### `colorScheme.background` (20 instances)
Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`.
**Files affected:**
- lib/main.dart:335, 336
- lib/screens/library_artists_screen.dart:21
- lib/screens/new_home_screen.dart:121
- lib/screens/artist_details_screen.dart:708, 720
- lib/screens/library_playlists_screen.dart:53
- lib/screens/library_tracks_screen.dart:19
- lib/screens/login_screen.dart:489
- lib/screens/new_library_screen.dart:933, 957, 1020, 1021
- lib/screens/album_details_screen.dart:807, 819
- lib/screens/library_albums_screen.dart:19
- lib/screens/search_screen.dart:427, 738, 739
- lib/screens/settings_screen.dart:245
---
## 2. HIGH: Missing const Constructors
### EdgeInsets.zero (64 instances)
High-impact optimization - used extensively in button padding.
**Key files:**
- lib/screens/album_details_screen.dart (12 instances)
- lib/screens/artist_details_screen.dart (8 instances)
- lib/screens/audiobook_detail_screen.dart (3 instances)
- lib/screens/audiobook_series_screen.dart (2 instances)
- lib/screens/audiobook_author_screen.dart (2 instances)
### BorderRadius.circular (40+ instances)
Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`.
---
## 3. MEDIUM: Scroll Performance - Missing Image Cache Parameters
### CachedNetworkImage without memCacheWidth/Height
**lib/screens/new_library_screen.dart:**
- Line 1395-1402: Author image (48x48) - missing cache params
- Line 1480-1486: Author card image - missing cache params
- Line 1572-1580: Audiobook cover (56x56) - missing cache params
- Line 1740-1745: Audiobook grid cover - missing cache params
- Line 1959-1969: Playlist cover (56x56) - missing cache params
- Line 2143-2152: Playlist grid covers - missing cache params
- Line 2973-2981: Album cover (56x56) - missing cache params
**Note:** DecorationImage with CachedNetworkImageProvider cannot have cache size specified (structural Flutter limitation).
---
## 4. LOW: Code Duplication
### View Mode Cycling Functions (8 nearly identical functions)
**File:** lib/screens/new_library_screen.dart
Functions that follow identical switch/case pattern:
- `_cycleArtistsViewMode()` (line 184)
- `_cycleAlbumsViewMode()` (line 200)
- `_cyclePlaylistsViewMode()` (line 216)
- `_cycleAuthorsViewMode()` (line 232)
- `_cycleAudiobooksViewMode()` (line 248)
- `_cycleSeriesViewMode()` (line 286)
- `_cycleRadioViewMode()` (line 303)
- `_cyclePodcastsViewMode()` (line 319)
**Recommendation:** Create generic utility function.
### Duplicated Color Constants (3 files)
Series fallback colors defined identically in:
- lib/widgets/series_row.dart:246-253
- lib/screens/audiobook_series_screen.dart:466-473
- lib/screens/new_library_screen.dart:2112-2119
**Recommendation:** Extract to lib/constants/colors.dart
---
## 5. POSITIVE FINDINGS (Already Optimized)
- **RepaintBoundary**: Properly implemented in all list item widgets
- **ListView.builder**: Used consistently with cacheExtent: 1000
- **ScrollController disposal**: All 8+ controllers properly disposed
- **Animation patterns**: Proper listener cleanup in expandable_player.dart
- **Debounced operations**: Color extraction debounced to 300ms
---
## Recommended Action Plan
### Phase 1: Quick Wins (Low Risk)
1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations)
2. Add `const` to EdgeInsets.zero instances
### Phase 2: Image Optimization
1. Add memCacheWidth/memCacheHeight to CachedNetworkImage instances in new_library_screen.dart
### Phase 3: Code Cleanup (Optional)
1. Extract view mode cycling to utility function
2. Centralize color constants
---
## Files by Complexity
| File | Lines | Status |
|------|-------|--------|
| lib/screens/search_screen.dart | 3,489 | Good structure, minor fixes needed |
| lib/screens/new_library_screen.dart | 3,351 | Good performance, some duplication |
| lib/widgets/expandable_player.dart | 2,577 | Well-optimized animations |
+57
View File
@@ -0,0 +1,57 @@
# Audit Summary
**Branch:** `audit/code-quality-review`
**Date:** January 4, 2026
## Changes Applied
### Phase 1: Analysis Complete
- Created `AUDIT_REPORT.md` with comprehensive findings
- 4 parallel subagents analyzed const constructors, scroll performance, code duplication, and dead code
### Phase 2: Deprecated API Fixes (18 files)
- Replaced all `colorScheme.background` with `colorScheme.surface` (Material 3 compliance)
**Files modified:**
- lib/screens/library_artists_screen.dart
- lib/screens/library_albums_screen.dart
- lib/screens/library_playlists_screen.dart
- lib/screens/library_tracks_screen.dart
- lib/screens/artist_details_screen.dart
- lib/screens/new_home_screen.dart
- lib/screens/login_screen.dart
- lib/screens/settings_screen.dart
- lib/screens/album_details_screen.dart
- lib/screens/search_screen.dart
- lib/screens/new_library_screen.dart
### Phase 3: Scroll Performance Optimization
- Added `memCacheWidth` and `memCacheHeight` to CachedNetworkImage instances
**lib/screens/new_library_screen.dart:**
- Line 1400-1401: Author list tile image (128x128 cache)
- Line 1487-1488: Author grid card image (256x256 cache)
- Line 1577-1578: Audiobook list tile cover (256x256 cache)
- Line 1749-1750: Audiobook grid cover (512x512 cache)
- Line 1970-1971: Series list tile cover (256x256 cache)
- Line 2986-2987: Album list tile cover (256x256 cache)
## Recommendations NOT Implemented (Low Priority)
These were identified but NOT changed to preserve functionality:
1. **View mode cycling refactor** - 8 similar functions could be consolidated
2. **Color constant extraction** - Duplicated colors in 3 files
3. **EdgeInsets.zero const** - 64 instances (build-time only, minimal impact)
4. **BorderRadius.circular const** - 40+ instances
## Verification
Changes should be verified by:
1. GitHub Actions build (triggered automatically)
2. Manual UI testing of scroll smoothness
3. Verify no visual regressions in dark/light themes
## Build Trigger
GitHub Actions workflow triggered on branch `audit/code-quality-review`
+66 -25
View File
@@ -20,44 +20,63 @@ This application was built with AI-assisted development using **Claude Code** an
## Features
### Local Playback
- **Stream to Your Phone** - Play music from your Music Assistant library directly on your mobile device
- **Stream to Your Phone** - Play music from your Music Assistant library directly on your mobile device via Sendspin protocol
- **Background Playback** - Music continues playing when the app is minimized
- **Media Notifications** - Control playback from your notification shade with album art display
- **Instant Response** - Pause/resume in ~300ms
### Remote Control
- **Multi-Player Support** - Control any speaker or device connected to Music Assistant
- **Device Selector** - Quickly switch between your phone and other players
- **Device Selector** - Swipe down on mini player to reveal all your devices
- **Multi-Room Grouping** - Long-press any player to sync it with the current player
- **Full Playback Controls** - Play, pause, skip, seek, and adjust volume
- **Queue Management** - View and manage the playback queue
- **Volume Precision Mode** - Hold the volume slider for fine-grained control with haptic feedback
- **Power Control** - Turn players on/off directly from the mini player
### Queue Management
- **View & Manage Queue** - See upcoming tracks in the playback queue
- **Drag to Reorder** - Instant drag handles for reordering tracks
- **Swipe to Delete** - Remove tracks with a simple swipe gesture
### Home Screen
- **Customizable Rows** - Toggle Recently Played, Discover Artists, and Discover Albums
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, and Tracks
- **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, Tracks, Playlists, and Radio Stations
- **Adaptive Layout** - Rows scale properly for different screen sizes and aspect ratios
- **Pull to Refresh** - Refresh content with a simple pull gesture
### Library Browsing
- **Browse Your Collection** - Artists, albums, playlists, and tracks from all your music sources
### Library
- **Music** - Browse artists, albums, playlists, and tracks from all your music sources
- **Radio Stations** - Browse and play radio stations with list or grid view
- **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates
- **Audiobooks** - Browse by title, series, or author with progress tracking
- **Favorites Filter** - Toggle to show only your favorite items
- **Album Details** - View track listings with artwork
- **Artist Details** - View artist albums and top tracks
- **Playlist Support** - Browse and play your playlists
- **Search** - Find music across your entire library
- **Letter Scrollbar** - Fast navigation through long lists
### Search
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
- **Fuzzy Matching** - Typo-tolerant search (e.g., "beetles" finds "Beatles")
- **Smart Scoring** - Results ranked by relevance with colored type indicators
- **Search History** - Quickly access your recent searches
- **Quick Actions** - Long-press any result to add to queue or play next
### Audiobooks
- **Audiobooks Tab** - Browse your audiobook library with grid/list view options
- **Series Support** - View audiobooks organized by series with collage cover art
- **Author Browsing** - Browse audiobooks by author
- **Chapter Navigation** - Jump between chapters with timestamp display
- **Progress Tracking** - Track your listening progress across sessions
- **Continue Listening** - Pick up where you left off
- **Mark as Finished/Unplayed** - Manage your reading progress
- **Series Support** - View audiobooks organized by series with collage cover art
### Podcasts
- **Episode Browser** - View full episode list with artwork and descriptions
- **Skip Controls** - Skip forward/backward during playback
- **High-Resolution Artwork** - Fetched via iTunes for best quality
### Smart Features
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
- **Auto-Reconnect** - Automatically reconnects when connection is lost
- **Offline Browsing** - Browse your cached library even when disconnected
- **Hero Animations** - Smooth transitions between screens
- **Welcome Screen** - Guided onboarding for first-time users
### Theming
- **Material You** - Dynamic theming based on your device's wallpaper
@@ -67,14 +86,16 @@ This application was built with AI-assisted development using **Claude Code** an
## Screenshots
<div align="center">
<img src="assets/screenshots/1.png?v=2" alt="Connection Screen" width="150">
<img src="assets/screenshots/2.png?v=2" alt="Home Screen" width="150">
<img src="assets/screenshots/3.png?v=2" alt="Album Details" width="150">
<img src="assets/screenshots/4.png?v=2" alt="Now Playing" width="150">
<img src="assets/screenshots/5.png?v=2" alt="Queue" width="150">
<img src="assets/screenshots/6.png?v=2" alt="Settings" width="150">
<img src="assets/screenshots/7.png?v=2" alt="Audiobooks" width="150">
<img src="assets/screenshots/8.png?v=2" alt="Audiobook Player" width="150">
<img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150">
<img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150">
<img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150">
<img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150">
<img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150">
<img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150">
<img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" width="150">
<img src="assets/screenshots/8.png?v=3" alt="Screenshot 8" width="150">
<img src="assets/screenshots/9.png?v=3" alt="Screenshot 9" width="150">
<img src="assets/screenshots/10.png?v=3" alt="Screenshot 10" width="150">
</div>
## Download
@@ -84,9 +105,29 @@ Download the latest release from the [Releases page](https://github.com/CollotsS
## Setup
1. Launch the app
2. Enter your Music Assistant server URL (e.g., `music.example.com` or `192.168.1.100`)
2. Enter your Music Assistant server URL
3. Connect to your server
4. Start playing! Music plays on your phone by default, or tap the device icon to choose a different player.
4. Start playing! Music plays on your phone by default, or swipe down on the mini player to choose a different player.
### Finding Your Server URL
**Important:** You need the **Music Assistant** URL, not your Home Assistant URL.
To find the correct URL:
1. Open Music Assistant web UI
2. Go to **Settings** > **About**
3. Look for **Base URL** (e.g., `http://192.168.1.100:8095`)
### Home Assistant Add-on Users
If you run Music Assistant as a Home Assistant add-on:
- Use the IP address of your Home Assistant server
- Enter `8095` in the port field
- Do **not** use your Home Assistant URL or ingress URL
### Remote Access
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
## Authentication
@@ -103,7 +144,7 @@ Ensemble supports multiple authentication methods:
## Requirements
- Music Assistant server (v2.7.0 beta 20 or later)
- Music Assistant server (v2.7.0 beta 20 or later recommended)
- Network connectivity to your Music Assistant server
- Android device (Android 5.0+)
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
+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
@@ -25,4 +25,19 @@ class HeroTags {
/// Audiobook author image hero tag prefix
static const String authorImage = 'author_image_';
/// Podcast cover hero tag prefix
static const String podcastCover = 'podcast_cover_';
/// 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_';
}
+6
View File
@@ -68,6 +68,12 @@
"tracks": "Songs",
"playlists": "Wiedergabelisten",
"audiobooks": "Hörbücher",
"music": "Musik",
"radio": "Radio",
"stations": "Sender",
"selectLibrary": "Bibliothek auswählen",
"noRadioStations": "Keine Radiosender",
"addRadioStationsHint": "Füge Radiosender in Music Assistant hinzu",
"searchFailed": "Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.",
"queue": "Warteschlange",
+84
View File
@@ -251,6 +251,16 @@
"description": "Home screen section title"
},
"favoritePlaylists": "Favorite Playlists",
"@favoritePlaylists": {
"description": "Home screen section title"
},
"favoriteRadioStations": "Favorite Radio Stations",
"@favoriteRadioStations": {
"description": "Home screen section title"
},
"searchMusic": "Search music...",
"@searchMusic": {
"description": "Search placeholder text"
@@ -316,6 +326,36 @@
"description": "Audiobooks category"
},
"music": "Music",
"@music": {
"description": "Music category"
},
"radio": "Radio",
"@radio": {
"description": "Radio category"
},
"stations": "Stations",
"@stations": {
"description": "Radio stations subcategory"
},
"selectLibrary": "Select Library",
"@selectLibrary": {
"description": "Title for library type selection bottom sheet"
},
"noRadioStations": "No radio stations",
"@noRadioStations": {
"description": "Empty state when no radio stations are available"
},
"addRadioStationsHint": "Add radio stations in Music Assistant",
"@addRadioStationsHint": {
"description": "Hint for adding radio stations"
},
"searchFailed": "Search failed. Please check your connection.",
"@searchFailed": {
"description": "Search error message"
@@ -630,6 +670,16 @@
"description": "Radio button label"
},
"playingRadioStation": "Playing {name}",
"@playingRadioStation": {
"description": "Status when playing radio station",
"placeholders": {
"name": {
"type": "String"
}
}
},
"addedToQueue": "Added to queue",
"@addedToQueue": {
"description": "Confirmation when item added to queue"
@@ -1022,10 +1072,15 @@
"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",
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
"addedToFavorites": "Added to favorites",
"removedFromFavorites": "Removed from favorites",
"addedToLibrary": "Added to library",
"removedFromLibrary": "Removed from library",
"addToLibrary": "Add to library",
"unknown": "Unknown",
"noUpcomingTracks": "No upcoming tracks",
"showAll": "Show all",
@@ -1036,6 +1091,10 @@
"shows": "Shows",
"podcasts": "Podcasts",
"podcastSupportComingSoon": "Podcast support coming soon",
"noPodcasts": "No podcasts",
"addPodcastsHint": "Subscribe to podcasts in Music Assistant",
"episodes": "Episodes",
"episode": "Episode",
"playlist": "Playlist",
"connectionError": "Connection Error",
"twoColumnGrid": "2-column grid",
@@ -1168,6 +1227,11 @@
"description": "Singular form of track for search type indicator"
},
"podcastSingular": "Podcast",
"@podcastSingular": {
"description": "Singular form of podcast for search type indicator"
},
"trackCount": "{count} {count, plural, =1{track} other{tracks}}",
"@trackCount": {
"description": "Track count with plural",
@@ -1306,5 +1370,25 @@
"dismissPlayerHint": "Swipe down, tap outside, or press back to return",
"@dismissPlayerHint": {
"description": "Hint explaining how to dismiss the player selector"
},
"playingAlbum": "Playing {albumName}",
"@playingAlbum": {
"description": "Message shown when starting to play an album",
"placeholders": {
"albumName": {
"type": "String"
}
}
},
"playingPlaylist": "Playing {playlistName}",
"@playingPlaylist": {
"description": "Message shown when starting to play a playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
}
}
+6
View File
@@ -66,6 +66,12 @@
"tracks": "Canciones",
"playlists": "Listas de reproducción",
"audiobooks": "Audiolibros",
"music": "Música",
"radio": "Radio",
"stations": "Emisoras",
"selectLibrary": "Seleccionar Biblioteca",
"noRadioStations": "No hay emisoras de radio",
"addRadioStationsHint": "Añade emisoras de radio en Music Assistant",
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
"queue": "Cola",
"playerQueue": "Cola de {playerName}",
+29 -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);
@@ -332,8 +350,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,
),
);
+10 -1
View File
@@ -4,6 +4,7 @@ import '../services/music_assistant_api.dart';
import '../services/debug_logger.dart';
import '../services/error_handler.dart';
import '../services/cache_service.dart';
import '../services/settings_service.dart';
import '../constants/timings.dart';
/// Provider for managing library data (artists, albums, tracks, playlists)
@@ -138,8 +139,12 @@ class LibraryProvider with ChangeNotifier {
_error = null;
notifyListeners();
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 loadLibrary using albumArtistsOnly: $showOnlyArtistsWithAlbums');
final results = await Future.wait([
_api!.getArtists(limit: LibraryConstants.maxLibraryItems),
_api!.getArtists(limit: LibraryConstants.maxLibraryItems, albumArtistsOnly: showOnlyArtistsWithAlbums),
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
]);
@@ -166,10 +171,14 @@ class LibraryProvider with ChangeNotifier {
_error = null;
notifyListeners();
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_artists = await _api!.getArtists(
limit: limit ?? LibraryConstants.maxLibraryItems,
offset: offset,
search: search,
albumArtistsOnly: showOnlyArtistsWithAlbums,
);
_isLoading = false;
File diff suppressed because it is too large Load Diff
+173 -34
View File
@@ -36,6 +36,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
List<Track> _tracks = [];
bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
@@ -52,6 +53,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void initState() {
super.initState();
_isFavorite = widget.album.favorite ?? false;
_isInLibrary = widget.album.inLibrary;
_loadTracks();
_loadAlbumDescription();
@@ -140,7 +142,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
orElse: () => widget.album.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
@@ -171,7 +174,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
throw Exception('Could not determine library ID for this album');
}
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
success = await maProvider.removeFromFavorites(
mediaType: 'album',
libraryItemId: libraryItemId,
@@ -214,6 +216,104 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
String? actualItemId;
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
// For adding to library, we MUST use a non-library provider
final nonLibraryMapping = widget.album.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
// Fallback to item's own provider if no non-library mapping found
if (actualProvider == null || actualItemId == null) {
if (widget.album.provider != 'library') {
actualProvider = widget.album.provider;
actualItemId = widget.album.itemId;
} else {
// Item is library-only, can't add
_logger.log('Cannot add to library: album is library-only');
return;
}
}
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
mediaType: 'album',
provider: actualProvider,
itemId: actualItemId,
);
} else {
// Remove from library
int? libraryItemId;
if (widget.album.provider == 'library') {
libraryItemId = int.tryParse(widget.album.itemId);
} else if (widget.album.providerMappings != null) {
final libraryMapping = widget.album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.album.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Cannot remove from library: no library ID found');
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling album library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update library: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
Future<void> _toggleTrackFavorite(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
@@ -260,7 +360,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
orElse: () => track.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
@@ -706,8 +807,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
backgroundColor: colorScheme.background,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
@@ -731,35 +832,50 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
const SizedBox(height: 60),
GestureDetector(
onTap: () => _showFullscreenArt(imageUrl),
child: Hero(
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
image: imageUrl != null
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl),
fit: BoxFit.cover,
)
: null,
// Shadow container (outside Hero for correct clipping)
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Hero(
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
// FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
child: imageUrl == null
? Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
)
: null,
),
),
),
@@ -769,7 +885,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -910,6 +1026,29 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
const SizedBox(width: 12),
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 24),
+187 -4
View File
@@ -36,11 +36,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
List<Album> _providerAlbums = [];
bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
bool _isDescriptionExpanded = false;
String? _artistDescription;
String? _artistImageUrl;
MusicAssistantProvider? _maProvider;
// View preferences
String _sortOrder = 'alpha'; // 'alpha' or 'year'
@@ -52,6 +54,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
void initState() {
super.initState();
_isFavorite = widget.artist.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.artist);
// Use initial image URL immediately for smooth hero animation
_artistImageUrl = widget.initialImageUrl;
_loadViewPreferences();
@@ -66,11 +69,60 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
_loadArtistImage();
// Note: _extractColors is called by _loadArtistImage after image loads
// CRITICAL FIX: Delay adding provider listener until AFTER Hero animation
// Adding it immediately causes rebuilds during animation (jank)
_maProvider = context.read<MusicAssistantProvider>();
_maProvider?.addListener(_onProviderChanged);
}
});
});
}
void _onProviderChanged() {
if (!mounted) return;
// Re-check library status when provider data changes
final newIsInLibrary = _checkIfInLibraryFromProvider();
if (newIsInLibrary != _isInLibrary) {
setState(() {
_isInLibrary = newIsInLibrary;
});
}
}
/// Check if artist is in library using provider's artists list
bool _checkIfInLibraryFromProvider() {
if (_maProvider == null) return _checkIfInLibrary(widget.artist);
final artistName = widget.artist.name.toLowerCase();
final artistUri = widget.artist.uri;
// Check if this artist exists in the provider's library
return _maProvider!.artists.any((a) {
// Match by URI if available
if (artistUri != null && a.uri == artistUri) return true;
// Match by name as fallback
if (a.name.toLowerCase() == artistName) return true;
// Check provider mappings for matching URIs
if (widget.artist.providerMappings != null) {
for (final mapping in widget.artist.providerMappings!) {
if (a.providerMappings?.any((m) =>
m.providerInstance == mapping.providerInstance &&
m.itemId == mapping.itemId) == true) {
return true;
}
}
}
return false;
});
}
@override
void dispose() {
_maProvider?.removeListener(_onProviderChanged);
super.dispose();
}
Future<void> _loadViewPreferences() async {
final sortOrder = await SettingsService.getArtistAlbumsSortOrder();
final viewMode = await SettingsService.getArtistAlbumsViewMode();
@@ -172,7 +224,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
orElse: () => widget.artist.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
@@ -202,7 +255,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
throw Exception('Could not determine library ID for this artist');
}
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
success = await maProvider.removeFromFavorites(
mediaType: 'artist',
libraryItemId: libraryItemId,
@@ -243,6 +295,110 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
}
/// Check if artist is in library
bool _checkIfInLibrary(Artist artist) {
if (artist.provider == 'library') return true;
return artist.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
String? actualItemId;
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
// For adding to library, we MUST use a non-library provider
final nonLibraryMapping = widget.artist.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
// Fallback to item's own provider if no non-library mapping found
if (actualProvider == null || actualItemId == null) {
if (widget.artist.provider != 'library') {
actualProvider = widget.artist.provider;
actualItemId = widget.artist.itemId;
} else {
// Item is library-only, can't add
_logger.log('Cannot add to library: artist is library-only');
return;
}
}
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
mediaType: 'artist',
provider: actualProvider,
itemId: actualItemId,
);
} else {
// Remove from library
int? libraryItemId;
if (widget.artist.provider == 'library') {
libraryItemId = int.tryParse(widget.artist.itemId);
} else if (widget.artist.providerMappings != null) {
final libraryMapping = widget.artist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.artist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Cannot remove from library: no library ID found');
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling artist library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update library: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
void _showRadioMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
@@ -553,8 +709,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
backgroundColor: colorScheme.background,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 40% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.4).clamp(160.0, 280.0);
// Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
return CustomScrollView(
@@ -587,8 +743,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.person_rounded,
size: coverSize * 0.5,
@@ -712,6 +872,29 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(width: 12),
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 16),
+7 -4
View File
@@ -140,7 +140,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
orElse: () => widget.audiobook.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
@@ -169,7 +170,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
throw Exception('Could not determine library ID for this audiobook');
}
_logger.log('Removing audiobook from favorites: libraryItemId=$libraryItemId');
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
@@ -429,8 +429,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
@@ -512,6 +512,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
+2 -2
View File
@@ -277,8 +277,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
// Responsive cover size: 70% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
+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();
+1 -1
View File
@@ -64,7 +64,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: () async {
await context.read<MusicAssistantProvider>().loadLibrary();
},
+1 -1
View File
@@ -66,7 +66,7 @@ class LibraryArtistsScreen extends StatelessWidget {
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: () async {
await context.read<MusicAssistantProvider>().loadLibrary();
},
+6 -12
View File
@@ -30,17 +30,11 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
});
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final playlists = await maProvider.api!.getPlaylists(limit: 100);
setState(() {
_playlists = playlists;
_isLoading = false;
});
} else {
setState(() {
_isLoading = false;
});
}
final playlists = await maProvider.getPlaylists(limit: 100);
setState(() {
_playlists = playlists;
_isLoading = false;
});
}
@override
@@ -89,7 +83,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
return RefreshIndicator(
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
onRefresh: _loadPlaylists,
child: ListView.builder(
itemCount: _playlists.length,
+35 -6
View File
@@ -13,6 +13,8 @@ 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/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -35,6 +37,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
// Audiobook rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
@@ -73,6 +77,8 @@ 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 showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
@@ -85,6 +91,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
@@ -194,7 +202,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
RefreshIndicator(
onRefresh: _onRefresh,
color: colorScheme.primary,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.background,
child: _buildConnectedView(context, maProvider),
),
// Connecting banner overlay (doesn't affect layout)
@@ -243,8 +251,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
// Use LayoutBuilder to get available screen height
return LayoutBuilder(
builder: (context, constraints) {
// Simple calculation: available height divided by 3 rows
// Each row includes its title, artwork, and info
// Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
final rowHeight = availableHeight / 3;
@@ -330,7 +338,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 +347,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 +356,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 +384,24 @@ 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,
);
default:
return null;
}
File diff suppressed because it is too large Load Diff
+873
View File
@@ -0,0 +1,873 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
class PodcastDetailScreen extends StatefulWidget {
final MediaItem podcast;
final String? heroTagSuffix;
final String? initialImageUrl;
const PodcastDetailScreen({
super.key,
required this.podcast,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
State<PodcastDetailScreen> createState() => _PodcastDetailScreenState();
}
class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final _logger = DebugLogger();
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
List<MediaItem> _episodes = [];
bool _isLoadingEpisodes = false;
bool _isDescriptionExpanded = false;
bool _isInLibrary = false;
String? _expandedEpisodeId; // Track which episode is expanded for actions
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
void initState() {
super.initState();
_isInLibrary = _checkIfInLibrary(widget.podcast);
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
}
});
_loadEpisodes();
});
}
/// Check if podcast is in library
bool _checkIfInLibrary(MediaItem podcast) {
if (podcast.provider == 'library') return true;
return podcast.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
bool success;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
String? actualItemId;
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
// For adding to library, we MUST use a non-library provider
final nonLibraryMapping = widget.podcast.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
// Fallback to item's own provider if no non-library mapping found
if (actualProvider == null || actualItemId == null) {
if (widget.podcast.provider != 'library') {
actualProvider = widget.podcast.provider;
actualItemId = widget.podcast.itemId;
} else {
// Item is library-only, can't add
_logger.log('Cannot add to library: podcast is library-only');
return;
}
}
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
success = await maProvider.addToLibrary(
mediaType: 'podcast',
provider: actualProvider,
itemId: actualItemId,
);
} else {
// Remove from library
int? libraryItemId;
if (widget.podcast.provider == 'library') {
libraryItemId = int.tryParse(widget.podcast.itemId);
} else if (widget.podcast.providerMappings != null) {
final libraryMapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.podcast.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
_logger.log('Cannot remove from library: no library ID found');
return;
}
success = await maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success) {
setState(() {
_isInLibrary = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling podcast library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update library: $e'),
duration: const Duration(seconds: 2),
),
);
}
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
// Use initialImageUrl first for consistency with hero animation
final imageUrl = widget.initialImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
if (imageUrl != null) {
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
if (mounted && colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
}
} catch (e) {
_logger.log('🎙️ Error extracting colors: $e');
}
}
}
Future<void> _loadEpisodes() async {
if (_isLoadingEpisodes) return;
setState(() {
_isLoadingEpisodes = true;
});
try {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
if (mounted) {
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
// Debug: Log first episode metadata to see available fields
if (episodes.isNotEmpty) {
final first = episodes.first;
_logger.log('🎙️ First episode metadata keys: ${first.metadata?.keys.toList()}');
_logger.log('🎙️ First episode metadata: ${first.metadata}');
}
setState(() {
_episodes = episodes;
});
}
}
} catch (e) {
_logger.log('🎙️ Error loading episodes: $e');
} finally {
if (mounted) {
setState(() {
_isLoadingEpisodes = false;
});
}
}
}
void _playEpisode(MediaItem episode) async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) {
try {
// Set podcast context before playing so player UI shows correct podcast name
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing: ${episode.name}'),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('🎙️ Error playing episode: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play episode: $e')),
);
}
}
} else {
_showPlayOnMenu(context, episode);
}
}
void _showPlayOnMenu(BuildContext context, MediaItem episode) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
// Set podcast context before playing so player UI shows correct podcast name
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
String _formatDuration(Duration? duration) {
if (duration == null || duration == Duration.zero) return '';
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes} min';
}
/// Format release date from episode metadata
/// Tries various common field names for publication date
String? _formatReleaseDate(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
// Try various common field names for publication date
dynamic dateValue = metadata['published'] ??
metadata['pub_date'] ??
metadata['release_date'] ??
metadata['aired'] ??
metadata['timestamp'];
if (dateValue == null) return null;
try {
DateTime? date;
if (dateValue is String) {
date = DateTime.tryParse(dateValue);
} else if (dateValue is int) {
// Unix timestamp
date = DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
}
if (date != null) {
// Format as "Jan 15, 2024"
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
} catch (e) {
_logger.log('🎙️ Error parsing release date: $e');
}
return null;
}
/// Add episode to queue on current player
Future<void> _addToQueue(MediaItem episode) async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
// Convert MediaItem to Track for queue addition
final track = Track(
itemId: episode.itemId,
provider: episode.provider,
name: episode.name,
uri: episode.uri,
duration: episode.duration,
metadata: episode.metadata,
);
await maProvider.addTrackToQueue(selectedPlayer.playerId, track);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('🎙️ Error adding episode to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))),
);
}
}
}
@override
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation
// watch() rebuilds on ANY provider change (library updates, etc.) causing animation jank
// select() only rebuilds when the specific podcast image URL changes
final providerImageUrl = context.select<MusicAssistantProvider, String?>(
(provider) => provider.getPodcastImageUrl(widget.podcast),
);
// Use initialImageUrl for seamless hero animation (same URL as source)
final imageUrl = widget.initialImageUrl ?? providerImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveLightScheme,
);
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveDarkScheme,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
ColorScheme? adaptiveScheme;
if (adaptiveTheme) {
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
}
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final description = widget.podcast.metadata?['description'] as String?;
final author = widget.podcast.metadata?['author'] as String?;
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
clearAdaptiveColorsOnBack(context);
}
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () {
clearAdaptiveColorsOnBack(context);
Navigator.pop(context);
},
color: colorScheme.onSurface,
),
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
Hero(
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + _heroTagSuffix,
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Podcast title
Text(
widget.podcast.name,
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
if (author != null) ...[
const SizedBox(height: 4),
Text(
author,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 16),
// Action buttons row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
// Main Play Latest Episode Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _isLoadingEpisodes || _episodes.isEmpty
? null
: () => _playEpisode(_episodes.first),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor: colorScheme.primary.withOpacity(0.38),
disabledForegroundColor: colorScheme.onPrimary.withOpacity(0.38),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
const SizedBox(width: 12),
// "Play on..." Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoadingEpisodes || _episodes.isEmpty
? null
: () => _showPlayOnMenu(context, _episodes.first),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// Library button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: 16),
// Description
if (description != null && description.isNotEmpty) ...[
GestureDetector(
onTap: () {
setState(() {
_isDescriptionExpanded = !_isDescriptionExpanded;
});
},
child: AnimatedCrossFade(
firstChild: Text(
description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
secondChild: Text(
description,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
crossFadeState: _isDescriptionExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
),
const SizedBox(height: 16),
],
// Episodes header
Row(
children: [
Text(
S.of(context)!.episodes,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(width: 8),
if (_episodes.isNotEmpty)
Text(
'(${_episodes.length})',
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
],
),
),
),
// Episodes list
if (_isLoadingEpisodes)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: CircularProgressIndicator(color: colorScheme.primary),
),
),
)
else if (_episodes.isEmpty)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
MdiIcons.microphoneOff,
size: 48,
color: colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
'No episodes found',
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final episode = _episodes[index];
final episodeDescription = episode.metadata?['description'] as String?;
final duration = episode.duration;
final episodeId = episode.uri ?? episode.itemId;
final isExpanded = _expandedEpisodeId == episodeId;
final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
// Try to get release date from metadata
final releaseDate = _formatReleaseDate(episode.metadata);
return Column(
children: [
InkWell(
onTap: () => _playEpisode(episode),
onLongPress: () {
setState(() {
_expandedEpisodeId = isExpanded ? null : episodeId;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode cover (bigger than before)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 72,
height: 72,
color: colorScheme.surfaceContainerHighest,
child: episodeImageUrl != null
? CachedNetworkImage(
imageUrl: episodeImageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.name,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
episodeDescription,
maxLines: isExpanded ? 100 : 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
const SizedBox(height: 6),
// Duration and release date row
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 14,
color: colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 4),
Text(
_formatDuration(duration),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
if (duration != null && duration > Duration.zero && releaseDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (releaseDate != null)
Text(
releaseDate,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
],
),
),
],
),
),
),
// Expanded action buttons (matching album track style)
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
child: Row(
children: [
// Play button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _playEpisode(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.play_arrow, size: 20),
),
),
const SizedBox(width: 10),
// Play on... button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context, episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addToQueue(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
],
),
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
);
},
childCount: _episodes.length,
),
),
// Bottom padding for mini player
const SliverToBoxAdapter(
child: SizedBox(height: 100),
),
],
);
},
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+117 -5
View File
@@ -29,6 +29,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
bool _showFavoriteTracks = false;
bool _showFavoritePlaylists = false;
bool _showFavoriteRadioStations = false;
// Audiobook home rows (default off)
bool _showContinueListeningAudiobooks = false;
bool _showDiscoverAudiobooks = false;
@@ -41,8 +43,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
bool _volumePrecisionMode = true;
// Hint settings
bool _showHints = true;
// Library settings
bool _showOnlyArtistsWithAlbums = false;
@override
void initState() {
@@ -68,6 +73,8 @@ 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();
// Load audiobook home row settings
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
@@ -90,10 +97,14 @@ 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();
// Load library settings
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -102,6 +113,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showContinueListeningAudiobooks = showContinueAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
@@ -110,7 +123,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
_libraryEnabled = libraryEnabled;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
});
}
}
@@ -144,6 +159,10 @@ 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};
default:
return {'title': rowId, 'subtitle': ''};
}
@@ -170,6 +189,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
return _showFavoriteArtists;
case 'favorite-tracks':
return _showFavoriteTracks;
case 'favorite-playlists':
return _showFavoritePlaylists;
case 'favorite-radio-stations':
return _showFavoriteRadioStations;
default:
return false;
}
@@ -215,6 +238,14 @@ 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;
}
});
}
@@ -281,13 +312,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Logo - same size as login screen (50% of screen width)
// Use color filter to make logo dark in light theme
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 48.0),
child: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
child: Theme.of(context).brightness == Brightness.light
? ColorFiltered(
colorFilter: const ColorFilter.mode(
Color(0xFF1a1a1a), // Dark color for light theme
BlendMode.srcIn,
),
child: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
)
: Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
),
// Connection status box - centered with border radius like theme boxes
@@ -702,6 +746,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),
@@ -745,6 +814,49 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Library section
Text(
S.of(context)!.library,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Show only artists with albums',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hide artists that have no albums in your library',
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 12,
),
),
value: _showOnlyArtistsWithAlbums,
onChanged: (value) async {
setState(() => _showOnlyArtistsWithAlbums = value);
await SettingsService.setShowOnlyArtistsWithAlbums(value);
// Force sync library to apply the new filter at API level
if (mounted) {
context.read<MusicAssistantProvider>().forceLibrarySync();
}
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
// Home Screen section
Text(
S.of(context)!.homeScreen,
+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
+123 -2
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,14 +118,101 @@ 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');
}
/// Invalidate home album caches (call when albums are added/removed from library)
void invalidateHomeAlbumCaches() {
_cachedRecentAlbums = null;
_recentAlbumsLastFetched = null;
_cachedDiscoverAlbums = null;
_discoverAlbumsLastFetched = null;
_logger.log('🗑️ Home album caches invalidated');
}
/// Invalidate home artist caches (call when artists are added/removed from library)
void invalidateHomeArtistCaches() {
_cachedDiscoverArtists = null;
_discoverArtistsLastFetched = null;
_logger.log('🗑️ Home artist caches invalidated');
}
// ============================================================================
// DETAIL SCREEN CACHING
// ============================================================================
@@ -145,12 +238,19 @@ class CacheService {
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
}
/// Invalidate album tracks cache
/// Invalidate album tracks cache for a specific album
void invalidateAlbumTracksCache(String albumId) {
_albumTracksCache.remove(albumId);
_albumTracksCacheTime.remove(albumId);
}
/// Invalidate all album tracks caches (call when tracks are added/removed from library)
void invalidateAllAlbumTracksCaches() {
_albumTracksCache.clear();
_albumTracksCacheTime.clear();
_logger.log('🗑️ All album tracks caches invalidated');
}
/// Check if playlist tracks cache is valid
bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
@@ -172,12 +272,19 @@ class CacheService {
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
}
/// Invalidate playlist tracks cache
/// Invalidate playlist tracks cache for a specific playlist
void invalidatePlaylistTracksCache(String playlistId) {
_playlistTracksCache.remove(playlistId);
_playlistTracksCacheTime.remove(playlistId);
}
/// Invalidate all playlist tracks caches (call when tracks are added/removed from library)
void invalidateAllPlaylistTracksCaches() {
_playlistTracksCache.clear();
_playlistTracksCacheTime.clear();
_logger.log('🗑️ All playlist tracks caches invalidated');
}
/// Check if artist albums cache is valid
bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
@@ -199,6 +306,13 @@ class CacheService {
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
}
/// Invalidate all artist albums caches (call when albums are added/removed from library)
void invalidateArtistAlbumsCache() {
_artistAlbumsCache.clear();
_artistAlbumsCacheTime.clear();
_logger.log('🗑️ Artist albums cache invalidated');
}
// ============================================================================
// SEARCH CACHING
// ============================================================================
@@ -224,6 +338,13 @@ class CacheService {
_logger.log('✅ Cached search results for "$cacheKey"');
}
/// Invalidate all search cache (call when library items change)
void invalidateSearchCache() {
_searchCache.clear();
_searchCacheTime.clear();
_logger.log('🗑️ Search cache invalidated');
}
// ============================================================================
// PLAYER CACHING
// ============================================================================
+10
View File
@@ -157,6 +157,16 @@ class DatabaseService {
/// Clear all cached data (useful for logout)
Future<void> clearAllCache() => db.clearAllCache();
/// Clear cached items of a specific type (e.g., 'album', 'artist')
/// Used before re-syncing to remove stale items
Future<void> clearCacheForType(String itemType) => db.clearCache(itemType);
/// Mark a specific cached item as deleted
/// Used when removing items from library for immediate database update
Future<void> markCachedItemDeleted(String itemType, String itemId) {
return db.markItemsDeleted(itemType, [itemId]);
}
// ============================================
// Player Cache Convenience Methods
// ============================================
+544 -71
View File
@@ -293,16 +293,10 @@ class MusicAssistantAPI {
// Handle event
final eventType = data['event'] as String?;
if (eventType != null) {
_logger.log('Event received: $eventType');
// Get the object_id (player_id for player events)
final objectId = data['object_id'] as String?;
final eventData = data['data'] as Map<String, dynamic>? ?? {};
// Debug: Log full data for player events
if (eventType == 'player_added' || eventType == 'player_updated' || eventType == 'builtin_player') {
_logger.log('Event data: ${jsonEncode(eventData)} (player_id: $objectId)');
}
// Include object_id in event data so listeners can filter by player
final enrichedData = {
@@ -498,6 +492,227 @@ class MusicAssistantAPI {
}
}
Future<List<MediaItem>> getRadioStations({
int? limit,
int? offset,
bool? favoriteOnly,
}) async {
try {
final response = await _sendCommand(
'music/radios/library_items',
args: {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly,
},
);
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
if (items == null) return [];
return items
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting radio stations: $e');
return [];
}
}
/// Search for radio stations globally (across all providers like TuneIn)
Future<List<MediaItem>> searchRadioStations(String query, {int limit = 25}) async {
try {
final response = await _sendCommand(
'music/search',
args: {
'search_query': query,
'media_types': ['radio'],
'limit': limit,
},
);
final result = response['result'] as Map<String, dynamic>?;
if (result == null) return [];
// Radio results might be under 'radios' or 'radio' key
final radios = (result['radios'] as List<dynamic>?) ??
(result['radio'] as List<dynamic>?) ??
[];
return radios
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error searching radio stations: $e');
return [];
}
}
// ============ PODCASTS ============
/// Get all podcasts from the library
Future<List<MediaItem>> getPodcasts({
int? limit,
int? offset,
bool? favoriteOnly,
}) async {
try {
final response = await _sendCommand(
'music/podcasts/library_items',
args: {
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
if (favoriteOnly != null) 'favorite': favoriteOnly,
},
);
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
if (items == null) return [];
return items
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting podcasts: $e');
return [];
}
}
/// Get episodes for a specific podcast
Future<List<MediaItem>> getPodcastEpisodes(String podcastId, {
String? provider,
int? limit,
int? offset,
}) async {
try {
final response = await _sendCommand(
'music/podcasts/podcast_episodes',
args: {
'item_id': podcastId,
if (provider != null) 'provider_instance_id_or_domain': provider,
},
);
final episodes = response['result'] as List<dynamic>?;
if (episodes != null && episodes.isNotEmpty) {
return episodes
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
}
return [];
} catch (e) {
_logger.log('Error getting podcast episodes: $e');
return [];
}
}
/// Get best available image URL for a podcast
/// Falls back to first episode's image if podcast image is unavailable or low quality
Future<String?> getPodcastCoverUrl(MediaItem podcast, {int size = 1024}) async {
// First try the podcast's own image
final podcastImage = getImageUrl(podcast, size: size);
// Check if podcast has images in metadata
final images = podcast.metadata?['images'] as List<dynamic>?;
if (images != null && images.isNotEmpty) {
// Podcast has images, use them
return podcastImage;
}
// No podcast image, try to get first episode's image
try {
final episodes = await getPodcastEpisodes(
podcast.itemId,
provider: podcast.provider,
);
if (episodes.isNotEmpty) {
final firstEpisode = episodes.first;
final episodeImage = getImageUrl(firstEpisode, size: size);
if (episodeImage != null) {
return episodeImage;
}
}
} catch (e) {
// Fall through to return podcast image
}
// Fall back to podcast image even if it might be low quality
return podcastImage;
}
/// Parse browse results into MediaItem episodes
List<MediaItem> _parseBrowseEpisodes(List<dynamic> items) {
final episodes = <MediaItem>[];
for (final item in items) {
if (item is Map<String, dynamic>) {
final name = item['name'] as String? ??
item['label'] as String? ??
'Episode';
final uri = item['uri'] as String? ?? item['path'] as String?;
final itemId = item['item_id'] as String? ?? uri ?? '';
final provider = item['provider'] as String? ?? 'library';
// Parse media type from string
final mediaTypeStr = item['media_type'] as String?;
MediaType mediaType = MediaType.track;
if (mediaTypeStr != null) {
mediaType = MediaType.values.firstWhere(
(e) => e.name == mediaTypeStr || e.name == mediaTypeStr.toLowerCase(),
orElse: () => MediaType.track,
);
}
// Get duration if available
Duration? duration;
final durationVal = item['duration'];
if (durationVal is int) {
duration = Duration(seconds: durationVal);
} else if (durationVal is double) {
duration = Duration(seconds: durationVal.toInt());
}
episodes.add(MediaItem(
itemId: itemId,
provider: provider,
name: name,
uri: uri,
metadata: item['metadata'] as Map<String, dynamic>?,
mediaType: mediaType,
duration: duration,
));
}
}
return episodes;
}
/// Play a podcast episode
Future<void> playPodcastEpisode(String playerId, MediaItem episode) async {
try {
// Try episode.uri first, then construct from itemId
String uri;
if (episode.uri != null && episode.uri!.isNotEmpty) {
uri = episode.uri!;
} else {
// Construct URI based on media type
uri = 'library://podcast_episode/${episode.itemId}';
}
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing podcast episode: $e');
rethrow;
}
}
Future<List<Audiobook>> getAudiobooks({
int? limit,
int? offset,
@@ -511,7 +726,6 @@ class MusicAssistantAPI {
// If specific libraries are enabled, use browse API for filtering
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
_logger.log('📚 Using browse API for library filtering (${enabledLibraries.length} libraries enabled)');
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
}
@@ -524,8 +738,6 @@ class MusicAssistantAPI {
if (authorId != null) 'author_id': authorId,
};
_logger.log('📚 Calling music/audiobooks/library_items with args: $args');
final response = await _sendCommand(
'music/audiobooks/library_items',
args: args,
@@ -533,65 +745,21 @@ class MusicAssistantAPI {
// Check for error in response
if (response.containsKey('error_code')) {
_logger.log('📚 ERROR: ${response['error_code']} - ${response['details']}');
return [];
}
final items = response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('📚 Audiobooks: result is null');
return [];
}
_logger.log('📚 Audiobooks: found ${items.length} items from API');
// Log first item's structure including metadata to find chapters/series
if (items.isNotEmpty) {
final firstItem = items.first as Map<String, dynamic>;
_logger.log('📚 First item keys: ${firstItem.keys.toList()}');
_logger.log('📚 First item name: ${firstItem['name']}, media_type: ${firstItem['media_type']}');
// Log metadata contents - this is where chapters/series info likely lives
if (firstItem.containsKey('metadata')) {
final metadata = firstItem['metadata'] as Map<String, dynamic>?;
if (metadata != null) {
_logger.log('📚 METADATA keys: ${metadata.keys.toList()}');
for (final key in metadata.keys) {
final value = metadata[key];
if (value is List) {
_logger.log('📚 metadata[$key] (List, ${value.length}): ${value.take(2)}');
} else if (value is Map) {
_logger.log('📚 metadata[$key] (Map): ${(value as Map).keys.toList()}');
} else if (value != null) {
_logger.log('📚 metadata[$key]: $value');
}
}
}
}
// Check for chapters at top level too
if (firstItem.containsKey('chapters')) {
_logger.log('📚 CHAPTERS at top level: ${firstItem['chapters']}');
}
}
if (items == null) return [];
final audiobooks = <Audiobook>[];
final parseErrors = <String>[];
for (int i = 0; i < items.length; i++) {
try {
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
audiobooks.add(book);
} catch (e) {
parseErrors.add('Item $i: $e');
// Skip items that fail to parse
}
}
if (parseErrors.isNotEmpty) {
_logger.log('📚 Parse errors (${parseErrors.length}): ${parseErrors.take(3).join(", ")}');
}
_logger.log('📚 Successfully parsed ${audiobooks.length}/${items.length} audiobooks');
return audiobooks;
} catch (e, stack) {
_logger.log('Error getting audiobooks: $e');
@@ -691,6 +859,26 @@ class MusicAssistantAPI {
}
}
/// Play a radio station
Future<void> playRadioStation(String playerId, MediaItem station) async {
try {
final uri = station.uri ?? 'library://radio/${station.itemId}';
_logger.log('📻 Playing radio station: ${station.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing radio station: $e');
rethrow;
}
}
/// Get full audiobook details including chapters
Future<Audiobook?> getAudiobookDetails(String provider, String itemId) async {
try {
@@ -1051,6 +1239,7 @@ class MusicAssistantAPI {
);
final items = response['result'] as List<dynamic>?;
_logger.log('📋 recently_played_items returned ${items?.length ?? 0} items');
if (items == null || items.isEmpty) {
_logger.log('⚠️ No recently played tracks found');
return [];
@@ -1060,6 +1249,8 @@ class MusicAssistantAPI {
final trackUris = <String>[];
for (final item in items) {
final trackUri = (item as Map<String, dynamic>)['uri'] as String?;
final name = (item as Map<String, dynamic>)['name'] as String?;
_logger.log(' 📀 Track: $name');
if (trackUri != null) {
trackUris.add(trackUri);
}
@@ -1089,12 +1280,17 @@ class MusicAssistantAPI {
try {
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
final trackName = fullTrack?['name'] as String?;
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
final albumUri = albumData?['uri'] as String?;
final albumName = albumData?['name'] as String?;
if (albumUri != null && !seenAlbumUris.contains(albumUri)) {
seenAlbumUris.add(albumUri);
albumUris.add(albumUri);
_logger.log(' 📀 Found album: $albumName from track: $trackName');
} else if (albumUri == null) {
_logger.log(' ⚠️ Track "$trackName" has no album');
}
} catch (_) {
continue;
@@ -1363,20 +1559,38 @@ class MusicAssistantAPI {
/// Remove item from favorites
/// Requires the library_item_id (the numeric ID in the MA library)
Future<void> removeFromFavorites(String mediaType, int libraryItemId) async {
try {
_logger.log('Removing from favorites: mediaType=$mediaType, libraryItemId=$libraryItemId');
await _sendCommand(
'music/favorites/remove_item',
args: {
'media_type': mediaType,
'library_item_id': libraryItemId,
},
);
}
await _sendCommand(
'music/favorites/remove_item',
args: {
'media_type': mediaType,
'library_item_id': libraryItemId,
},
);
} catch (e) {
_logger.log('Error removing from favorites: $e');
rethrow;
}
// Library
/// Add item to library using URI format
/// The item parameter should be a URI like "spotify://artist/4tZwfgrHOc3mvqYlEYSvVi"
Future<void> addItemToLibrary(String mediaType, String itemId, String provider) async {
final uri = '$provider://$mediaType/$itemId';
await _sendCommand(
'music/library/add_item',
args: {
'item': uri,
},
);
}
/// Remove item from library
/// Requires the library_item_id (the numeric ID in the MA library)
Future<void> removeItemFromLibrary(String mediaType, int libraryItemId) async {
await _sendCommand(
'music/library/remove_item',
args: {
'media_type': mediaType,
'library_item_id': libraryItemId,
},
);
}
// Search
@@ -1399,7 +1613,7 @@ class MusicAssistantAPI {
final result = searchResponse['result'] as Map<String, dynamic>?;
if (result == null) {
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': []};
}
// Parse results from the search response
@@ -1428,6 +1642,16 @@ class MusicAssistantAPI {
.toList() ??
[];
final radios = (result['radios'] as List<dynamic>?)
?.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList() ??
[];
final podcasts = (result['podcasts'] as List<dynamic>?)
?.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList() ??
[];
// Deduplicate results by name
// MA returns both library items and provider items for the same content
// Prefer library items (provider='library') as they have all provider mappings
@@ -1437,18 +1661,55 @@ class MusicAssistantAPI {
'tracks': _deduplicateResults(tracks),
'playlists': _deduplicateResults(playlists),
'audiobooks': _deduplicateResults(audiobooks),
'radios': _deduplicateResults(radios),
'podcasts': _deduplicateResults(podcasts),
};
} catch (e) {
_logger.log('Error searching: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []};
}
},
).catchError((e) {
_logger.log('Error searching after retries: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []};
});
}
/// Search for podcasts globally (across all providers)
Future<List<MediaItem>> searchPodcasts(String query, {int limit = 25}) async {
try {
_logger.log('🎙️ Searching podcasts for: $query');
final response = await _sendCommand(
'music/search',
args: {
'search_query': query,
'media_types': ['podcast'],
'limit': limit,
},
);
final result = response['result'] as Map<String, dynamic>?;
if (result == null) {
_logger.log('🎙️ Podcast search: no result');
return [];
}
// Podcast results might be under 'podcasts' or 'podcast' key
final podcasts = (result['podcasts'] as List<dynamic>?) ??
(result['podcast'] as List<dynamic>?) ??
[];
_logger.log('🎙️ Podcast search found ${podcasts.length} results');
return podcasts
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('🎙️ Error searching podcasts: $e');
return [];
}
}
/// Deduplicate search results by name (case-insensitive)
/// Prefers library items (provider='library') over provider-specific items
/// since library items have complete provider mappings
@@ -1634,6 +1895,86 @@ class MusicAssistantAPI {
}
}
/// Play album on player
Future<void> playAlbum(String playerId, Album album) async {
try {
final uri = album.uri ?? 'library://album/${album.itemId}';
_logger.log('💿 Playing album: ${album.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing album: $e');
rethrow;
}
}
/// Add album tracks to queue
Future<void> addAlbumToQueue(String playerId, Album album) async {
try {
final uri = album.uri ?? 'library://album/${album.itemId}';
_logger.log('💿 Adding album to queue: ${album.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'add',
},
);
} catch (e) {
_logger.log('Error adding album to queue: $e');
rethrow;
}
}
/// Add artist tracks to queue
Future<void> addArtistToQueue(String playerId, Artist artist) async {
try {
final uri = artist.uri ?? 'library://artist/${artist.itemId}';
_logger.log('🎤 Adding artist to queue: ${artist.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'add',
},
);
} catch (e) {
_logger.log('Error adding artist to queue: $e');
rethrow;
}
}
/// Play playlist on player
Future<void> playPlaylist(String playerId, Playlist playlist) async {
try {
final uri = playlist.uri ?? 'library://playlist/${playlist.itemId}';
_logger.log('📝 Playing playlist: ${playlist.name} with URI: $uri');
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'replace',
},
);
} catch (e) {
_logger.log('Error playing playlist: $e');
rethrow;
}
}
/// Play multiple tracks via queue
/// If clearQueue is true, replaces the queue (default behavior)
/// If startIndex is provided, only tracks from that index onwards will be queued
@@ -2112,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
// ============================================================================
@@ -2139,6 +2545,22 @@ class MusicAssistantAPI {
return _eventStreams['player_added']!.stream;
}
/// Stream of media_item_added events (for refreshing library when items are added)
Stream<Map<String, dynamic>> get mediaItemAddedEvents {
if (!_eventStreams.containsKey('media_item_added')) {
_eventStreams['media_item_added'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['media_item_added']!.stream;
}
/// Stream of media_item_deleted events (for refreshing library when items are removed)
Stream<Map<String, dynamic>> get mediaItemDeletedEvents {
if (!_eventStreams.containsKey('media_item_deleted')) {
_eventStreams['media_item_deleted'] = StreamController<Map<String, dynamic>>.broadcast();
}
return _eventStreams['media_item_deleted']!.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
@@ -2620,6 +3042,57 @@ class MusicAssistantAPI {
return '$baseUrl/imageproxy?provider=${Uri.encodeComponent(provider ?? "")}&size=$size&fmt=jpeg&path=${Uri.encodeComponent(imagePath)}';
}
// ==================== iTunes Artwork Lookup ====================
/// Search iTunes for a podcast and return high-res artwork URL
/// Returns null if not found or on error
Future<String?> getITunesPodcastArtwork(String podcastName) async {
try {
final searchTerm = Uri.encodeComponent(podcastName);
final url = 'https://itunes.apple.com/search?term=$searchTerm&entity=podcast&limit=1';
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 10);
final request = await client.getUrl(Uri.parse(url));
final response = await request.close();
if (response.statusCode != 200) {
_logger.log('🎨 iTunes search failed for "$podcastName": ${response.statusCode}');
return null;
}
final body = await response.transform(utf8.decoder).join();
final json = jsonDecode(body) as Map<String, dynamic>;
final results = json['results'] as List<dynamic>?;
if (results == null || results.isEmpty) {
_logger.log('🎨 No iTunes results for "$podcastName"');
return null;
}
final podcast = results.first as Map<String, dynamic>;
var artworkUrl = podcast['artworkUrl600'] as String?;
if (artworkUrl == null) {
artworkUrl = podcast['artworkUrl100'] as String?;
}
if (artworkUrl != null) {
// Use 800x800 - good quality without being excessive (was 1400x1400)
artworkUrl = artworkUrl
.replaceAll('600x600', '800x800')
.replaceAll('100x100', '800x800');
_logger.log('🎨 Found iTunes artwork for "$podcastName": $artworkUrl');
}
return artworkUrl;
} catch (e) {
_logger.log('🎨 iTunes lookup error for "$podcastName": $e');
return null;
}
}
// ==================== Authentication Methods ====================
/// Authenticate the WebSocket session with a token
+109
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 _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
// Default row order
@@ -48,6 +51,8 @@ class SettingsService {
'favorite-albums',
'favorite-artists',
'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
];
// View Mode Settings
@@ -66,6 +71,8 @@ class SettingsService {
static const String _keyLibrarySeriesViewMode = 'library_series_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keySeriesAudiobooksSortOrder = 'series_audiobooks_sort_order'; // 'alpha' or 'year'
static const String _keySeriesAudiobooksViewMode = 'series_audiobooks_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keyLibraryRadioViewMode = 'library_radio_view_mode'; // 'grid2', 'grid3', 'list'
static const String _keyLibraryPodcastsViewMode = 'library_podcasts_view_mode'; // 'grid2', 'grid3', 'list'
// Audiobookshelf Direct Integration Settings
static const String _keyAbsServerUrl = 'abs_server_url';
@@ -81,6 +88,12 @@ 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';
static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyServerUrl);
@@ -484,6 +497,37 @@ 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);
}
// Library Artists Filter - show only artists that have albums in library
static Future<bool> getShowOnlyArtistsWithAlbums() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowOnlyArtistsWithAlbums) ?? false;
}
static Future<void> setShowOnlyArtistsWithAlbums(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowOnlyArtistsWithAlbums, show);
}
// Home Row Order
static Future<List<String>> getHomeRowOrder() async {
final prefs = await SharedPreferences.getInstance();
@@ -621,6 +665,26 @@ class SettingsService {
await prefs.setString(_keyLibrarySeriesViewMode, mode);
}
static Future<String> getLibraryRadioViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryRadioViewMode) ?? 'list';
}
static Future<void> setLibraryRadioViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibraryRadioViewMode, mode);
}
static Future<String> getLibraryPodcastsViewMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLibraryPodcastsViewMode) ?? 'grid2';
}
static Future<void> setLibraryPodcastsViewMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLibraryPodcastsViewMode, mode);
}
// View Mode Settings - Series Audiobooks (series detail screen)
static Future<String> getSeriesAudiobooksSortOrder() async {
final prefs = await SharedPreferences.getInstance();
@@ -786,4 +850,49 @@ class SettingsService {
final prefs = await SharedPreferences.getInstance();
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", ...}
/// Get cached podcast cover URLs (iTunes high-res)
static Future<Map<String, String>> getPodcastCoverCache() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_keyPodcastCoverCache);
if (json == null) return {};
try {
final decoded = jsonDecode(json) as Map<String, dynamic>;
return decoded.map((key, value) => MapEntry(key, value as String));
} catch (_) {
return {};
}
}
/// Save podcast cover cache
static Future<void> setPodcastCoverCache(Map<String, String> cache) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyPodcastCoverCache, jsonEncode(cache));
}
/// Add a single podcast cover to cache (for incremental updates)
static Future<void> addPodcastCoverToCache(String podcastId, String url) async {
final cache = await getPodcastCoverCache();
cache[podcastId] = url;
await setPodcastCoverCache(cache);
}
}
+12 -1
View File
@@ -5,6 +5,7 @@ import '../models/media_item.dart';
import 'database_service.dart';
import 'debug_logger.dart';
import 'music_assistant_api.dart';
import 'settings_service.dart';
/// Sync status for UI indicators
enum SyncStatus {
@@ -150,10 +151,14 @@ class SyncService with ChangeNotifier {
try {
_logger.log('🔄 Starting background library sync...');
// Read artist filter setting - when ON, only fetch artists that have albums
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums');
// Fetch fresh data from MA API (in parallel for speed)
final results = await Future.wait([
api.getAlbums(limit: 1000),
api.getArtists(limit: 1000),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
api.getAudiobooks(limit: 1000),
api.getPlaylists(limit: 1000),
]);
@@ -166,6 +171,12 @@ class SyncService with ChangeNotifier {
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
// Clear old cache before saving fresh data (removes stale items)
await _db.clearCacheForType('album');
await _db.clearCacheForType('artist');
await _db.clearCacheForType('audiobook');
await _db.clearCacheForType('playlist');
// Save to database cache
await _saveAlbumsToCache(albums);
await _saveArtistsToCache(artists);
+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());
}
}
+31 -3
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -13,12 +14,16 @@ class AlbumCard extends StatefulWidget {
final Album album;
final VoidCallback? onTap;
final String? heroTagSuffix;
/// Image decode size in pixels. Defaults to 256.
/// Use smaller values (e.g., 128) for list views, larger for grids.
final int? imageCacheSize;
const AlbumCard({
super.key,
required this.album,
this.onTap,
this.heroTagSuffix,
this.imageCacheSize,
});
@override
@@ -30,6 +35,10 @@ class _AlbumCardState extends State<AlbumCard> {
bool _triedFallback = false;
bool _maImageFailed = false;
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@override
void initState() {
@@ -37,6 +46,12 @@ class _AlbumCardState extends State<AlbumCard> {
_initFallbackImage();
}
@override
void dispose() {
_fallbackTimer?.cancel();
super.dispose();
}
void _initFallbackImage() {
// Check if MA has an image after first build, then fetch fallback if needed
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -47,6 +62,16 @@ class _AlbumCardState extends State<AlbumCard> {
if (maImageUrl == null && !_triedFallback) {
_triedFallback = true;
_scheduleFallbackFetch();
}
});
}
/// Schedule fallback fetch with delay to avoid requests during fast scroll
void _scheduleFallbackFetch() {
_fallbackTimer?.cancel();
_fallbackTimer = Timer(_fallbackDelay, () {
if (mounted) {
_fetchFallbackImage();
}
});
@@ -69,7 +94,7 @@ class _AlbumCardState extends State<AlbumCard> {
if (!_triedFallback && !_maImageFailed) {
_maImageFailed = true;
_triedFallback = true;
_fetchFallbackImage();
_scheduleFallbackFetch();
}
}
@@ -86,6 +111,9 @@ class _AlbumCardState extends State<AlbumCard> {
// Use fallback if MA image failed or wasn't available
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
// PERF: Use appropriate cache size based on display size
final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
@@ -118,8 +146,8 @@ class _AlbumCardState extends State<AlbumCard> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
+18 -19
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) {
@@ -126,6 +121,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _albums.length,
@@ -135,13 +131,16 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = _albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
),
);
},
+29 -6
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:provider/provider.dart';
@@ -32,6 +33,10 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
String? _fallbackImageUrl;
bool _triedFallback = false;
bool _maImageFailed = false;
Timer? _fallbackTimer;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@override
void initState() {
@@ -39,6 +44,12 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
_loadImage();
}
@override
void dispose() {
_fallbackTimer?.cancel();
super.dispose();
}
Future<void> _loadImage() async {
final provider = context.read<MusicAssistantProvider>();
@@ -56,14 +67,23 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
return;
}
// Fallback to external sources (MA returned null)
_fetchFallbackImage();
// Fallback to external sources (MA returned null) - with delay
_scheduleFallbackFetch();
}
/// Schedule fallback fetch with delay to avoid requests during fast scroll
void _scheduleFallbackFetch() {
if (_triedFallback) return;
_triedFallback = true;
_fallbackTimer?.cancel();
_fallbackTimer = Timer(_fallbackDelay, () {
if (mounted) {
_fetchFallbackImage();
}
});
}
Future<void> _fetchFallbackImage() async {
if (_triedFallback) return;
_triedFallback = true;
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
_fallbackImageUrl = fallbackUrl;
@@ -79,7 +99,7 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
// When MA image fails to load, try Deezer fallback
if (!_maImageFailed) {
_maImageFailed = true;
_fetchFallbackImage();
_scheduleFallbackFetch();
}
}
@@ -100,6 +120,9 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
width: widget.radius * 2,
height: widget.radius * 2,
fit: BoxFit.cover,
// PERF: Use imageSize for memory cache to reduce decode overhead
memCacheWidth: widget.imageSize,
memCacheHeight: widget.imageSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
+34 -7
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -14,14 +15,16 @@ class ArtistCard extends StatefulWidget {
final Artist artist;
final VoidCallback? onTap;
final String? heroTagSuffix;
final double? imageSize;
/// Image decode size in pixels. Defaults to 256.
/// Use smaller values (e.g., 128) for list views, larger for grids.
final int? imageCacheSize;
const ArtistCard({
super.key,
required this.artist,
this.onTap,
this.heroTagSuffix,
this.imageSize,
this.imageCacheSize,
});
@override
@@ -34,6 +37,10 @@ class _ArtistCardState extends State<ArtistCard> {
bool _triedFallback = false;
bool _maImageFailed = false;
String? _cachedMaImageUrl;
Timer? _fallbackTimer;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
@override
void initState() {
@@ -42,6 +49,12 @@ class _ArtistCardState extends State<ArtistCard> {
_initFallbackImage();
}
@override
void dispose() {
_fallbackTimer?.cancel();
super.dispose();
}
void _initFallbackImage() {
// We'll check if MA has an image after first build, then fetch fallback if needed
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -52,7 +65,17 @@ class _ArtistCardState extends State<ArtistCard> {
if (maImageUrl == null && !_triedFallback) {
_triedFallback = true;
_logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
_logger.debug('No MA image for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard');
_scheduleFallbackFetch();
}
});
}
/// Schedule fallback fetch with delay to avoid requests during fast scroll
void _scheduleFallbackFetch() {
_fallbackTimer?.cancel();
_fallbackTimer = Timer(_fallbackDelay, () {
if (mounted) {
_fetchFallbackImage();
}
});
@@ -63,8 +86,8 @@ class _ArtistCardState extends State<ArtistCard> {
if (!_triedFallback && !_maImageFailed) {
_maImageFailed = true;
_triedFallback = true;
_logger.debug('MA image failed for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
_fetchFallbackImage();
_logger.debug('MA image failed for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard');
_scheduleFallbackFetch();
}
}
@@ -81,6 +104,9 @@ class _ArtistCardState extends State<ArtistCard> {
// Use fallback if MA image failed or wasn't available
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
// PERF: Use appropriate cache size based on display size
final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
@@ -115,8 +141,8 @@ class _ArtistCardState extends State<ArtistCard> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
@@ -163,6 +189,7 @@ class _ArtistCardState extends State<ArtistCard> {
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.15,
),
),
),
+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) {
+107 -67
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,9 +31,10 @@ 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;
@override
bool get wantKeepAlive => true;
@@ -38,25 +42,116 @@ 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;
return audiobooks;
});
_hasLoaded = true;
Future<void> _loadAudiobooks() async {
if (_hasLoaded) return;
_hasLoaded = true;
// Load fresh data (always update - fresh data may have updated progress)
try {
final freshAudiobooks = await widget.loadAudiobooks();
if (mounted && freshAudiobooks.isNotEmpty) {
setState(() {
_audiobooks = freshAudiobooks;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheAudiobookImages(freshAudiobooks);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
/// Pre-cache audiobook images so Hero animations are smooth on first tap
void _precacheAudiobookImages(List<Audiobook> audiobooks) {
if (!mounted || _hasPrecachedAudiobooks) return;
_hasPrecachedAudiobooks = true;
final maProvider = context.read<MusicAssistantProvider>();
// Only precache first ~10 visible items
final audiobooksToCache = audiobooks.take(10);
for (final audiobook in audiobooksToCache) {
final imageUrl = maProvider.api?.getImageUrl(audiobook, size: 256);
if (imageUrl != null) {
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
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>();
@@ -82,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
+101 -69
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,74 +490,44 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
builder: (context, _) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
// Use adaptive primary color for bottom nav when adaptive theme is enabled
final sourceColor = themeProvider.adaptiveTheme
? themeProvider.adaptivePrimaryColor
: colorScheme.primary;
// Use cached color computation to avoid expensive HSL operations during scroll
final isDark = Theme.of(context).brightness == Brightness.dark;
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Base background: use adaptive surface color if available, otherwise default surface
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
? themeProvider.adaptiveSurfaceColor!
: colorScheme.surface;
return ValueListenableBuilder<PlayerExpansionState>(
valueListenable: playerExpansionNotifier,
// Pass BottomNavigationBar as child to avoid rebuilding it every frame
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: const Icon(Icons.library_music_outlined),
activeIcon: const Icon(Icons.library_music_rounded),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
builder: (context, expansionState, navBar) {
// Only compute colors during animation - this is the hot path
final navBgColor = expansionState.progress > 0 && expansionState.backgroundColor != null
? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)!
: baseBgColor;
builder: (context, expansionState, _) {
// Nav bar color logic - only use adaptive colors when:
// 1. Player is expanding/expanded, OR
// 2. On a detail screen (isOnDetailScreen)
// On home screen with collapsed player: always use default theme colors
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
// Nav bar background color
final Color navBgColor;
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
// Player is expanding - blend from surface to player's adaptive color
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
} else if (useAdaptiveColors) {
// On a detail screen - use adaptive surface color for nav bar
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
navBgColor = adaptiveBg ?? colorScheme.surface;
} else {
// Home screen with collapsed player - use default surface color
navBgColor = colorScheme.surface;
}
// Icon color - only use adaptive colors when appropriate
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
? themeProvider.adaptiveColors!.primary
: colorScheme.primary;
// Blend icon color with player's primary color during expansion
Color sourceColor = baseSourceColor;
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
}
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Use AnimatedContainer for smoother transitions instead of rebuilding
return Container(
decoration: BoxDecoration(
color: navBgColor,
@@ -554,7 +541,50 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
]
: null,
),
child: navBar, // Reuse pre-built navigation bar
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: const Icon(Icons.library_music_outlined),
activeIcon: const Icon(Icons.library_music_rounded),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
);
},
);
@@ -643,7 +673,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
left: 24,
right: 24,
// Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).padding.bottom + 32,
// Use viewPadding to match BottomNavigationBar's height calculation
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).viewPadding.bottom + 32,
child: FadeTransition(
opacity: CurvedAnimation(
parent: _welcomeFadeController,
@@ -684,7 +715,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
PlayerRevealOverlay(
key: _revealKey,
onDismiss: _hidePlayerReveal,
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
// Use viewPadding to match BottomNavigationBar's height calculation
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
miniPlayerHeight: 64,
showOnboardingHints: _isOnboardingReveal,
),
+255
View File
@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
/// A scrollbar that shows a letter popup when dragging, for fast navigation
/// through alphabetically sorted lists.
class LetterScrollbar extends StatefulWidget {
/// The scrollable child widget (ListView, GridView, etc.)
final Widget child;
/// The scroll controller for the child
final ScrollController controller;
/// List of items to extract letters from (must be sorted alphabetically)
/// Each item's first character is used to determine the letter
final List<String> items;
/// Callback when user taps/drags to a specific index
final void Function(int index)? onScrollToIndex;
/// Callback when drag state changes (for disabling scroll-to-hide)
final void Function(bool isDragging)? onDragStateChanged;
const LetterScrollbar({
super.key,
required this.child,
required this.controller,
required this.items,
this.onScrollToIndex,
this.onDragStateChanged,
});
@override
State<LetterScrollbar> createState() => _LetterScrollbarState();
}
class _LetterScrollbarState extends State<LetterScrollbar> {
bool _isDragging = false;
String _currentLetter = '';
double _dragPosition = 0;
double _scrollFraction = 0;
@override
void initState() {
super.initState();
widget.controller.addListener(_onScrollChanged);
}
@override
void dispose() {
widget.controller.removeListener(_onScrollChanged);
super.dispose();
}
void _onScrollChanged() {
if (!_isDragging && widget.controller.hasClients) {
final position = widget.controller.position;
if (position.maxScrollExtent > 0) {
final newFraction = (position.pixels / position.maxScrollExtent).clamp(0.0, 1.0);
// PERF: Only rebuild if fraction changed by more than 1% to reduce rebuilds
if ((newFraction - _scrollFraction).abs() > 0.01) {
setState(() {
_scrollFraction = newFraction;
});
}
}
}
}
// Build a map of letter -> first index for that letter
Map<String, int> get _letterIndexMap {
final map = <String, int>{};
for (int i = 0; i < widget.items.length; i++) {
final item = widget.items[i];
if (item.isNotEmpty) {
final letter = item[0].toUpperCase();
if (!map.containsKey(letter)) {
map[letter] = i;
}
}
}
return map;
}
String _getLetterAtPosition(double position, double maxHeight) {
if (widget.items.isEmpty || maxHeight <= 0) return '';
// Calculate which item index corresponds to this position
final fraction = (position / maxHeight).clamp(0.0, 1.0);
final index = (fraction * (widget.items.length - 1)).round();
if (index >= 0 && index < widget.items.length) {
final item = widget.items[index];
if (item.isNotEmpty) {
return item[0].toUpperCase();
}
}
return '';
}
void _scrollToLetter(String letter) {
final letterMap = _letterIndexMap;
if (letterMap.containsKey(letter)) {
final index = letterMap[letter]!;
if (widget.onScrollToIndex != null) {
widget.onScrollToIndex!(index);
} else {
// Estimate scroll position based on index
// This is approximate - works better with fixed height items
final scrollController = widget.controller;
if (scrollController.hasClients) {
final maxScroll = scrollController.position.maxScrollExtent;
final fraction = index / widget.items.length;
final targetScroll = (fraction * maxScroll).clamp(0.0, maxScroll);
// Update scroll fraction immediately for smooth thumb tracking
setState(() {
_scrollFraction = fraction;
});
scrollController.jumpTo(targetScroll);
}
}
}
}
void _handleDragStart(DragStartDetails details) {
setState(() {
_isDragging = true;
_dragPosition = details.localPosition.dy;
});
widget.onDragStateChanged?.call(true);
_updateLetterAndScroll(details.localPosition.dy);
}
void _handleDragUpdate(DragUpdateDetails details) {
setState(() {
_dragPosition = details.localPosition.dy;
});
_updateLetterAndScroll(details.localPosition.dy);
}
void _handleDragEnd(DragEndDetails details) {
setState(() {
_isDragging = false;
});
widget.onDragStateChanged?.call(false);
}
void _updateLetterAndScroll(double position) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null) {
final height = renderBox.size.height;
final letter = _getLetterAtPosition(position, height);
if (letter.isNotEmpty && letter != _currentLetter) {
setState(() {
_currentLetter = letter;
});
_scrollToLetter(letter);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Stack(
children: [
// The scrollable content
widget.child,
// The draggable scrollbar area (right edge) - wider grab area
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 32, // Wider grab area
child: GestureDetector(
onVerticalDragStart: _handleDragStart,
onVerticalDragUpdate: _handleDragUpdate,
onVerticalDragEnd: _handleDragEnd,
onTapDown: (details) {
_handleDragStart(DragStartDetails(
localPosition: details.localPosition,
globalPosition: details.globalPosition,
));
},
onTapUp: (_) {
setState(() {
_isDragging = false;
});
widget.onDragStateChanged?.call(false);
},
behavior: HitTestBehavior.translucent,
child: LayoutBuilder(
builder: (context, constraints) {
final trackHeight = constraints.maxHeight;
final thumbHeight = _isDragging ? 60.0 : 40.0;
final availableTrack = trackHeight - thumbHeight;
final thumbTop = availableTrack * _scrollFraction;
return Stack(
children: [
// Thumb that tracks scroll position - use AnimatedPositioned for smooth motion
AnimatedPositioned(
duration: const Duration(milliseconds: 50),
curve: Curves.linear,
top: thumbTop,
right: 4,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
width: _isDragging ? 6 : 4,
height: thumbHeight,
decoration: BoxDecoration(
color: _isDragging
? colorScheme.primary
: colorScheme.onSurface.withOpacity(0.3),
borderRadius: BorderRadius.circular(3),
),
),
),
],
);
},
),
),
),
// The letter popup bubble
if (_isDragging && _currentLetter.isNotEmpty)
Positioned(
right: 48,
top: _dragPosition - 28,
child: Material(
elevation: 4,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 56,
height: 56,
alignment: Alignment.center,
child: Text(
_currentLetter,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
);
}
}
@@ -16,6 +16,7 @@ class DeviceSelectorBar extends StatelessWidget {
final GestureDragStartCallback? onHorizontalDragStart;
final GestureDragUpdateCallback? onHorizontalDragUpdate;
final GestureDragEndCallback? onHorizontalDragEnd;
final VoidCallback? onPowerToggle;
const DeviceSelectorBar({
super.key,
@@ -31,6 +32,7 @@ class DeviceSelectorBar extends StatelessWidget {
this.onHorizontalDragStart,
this.onHorizontalDragUpdate,
this.onHorizontalDragEnd,
this.onPowerToggle,
});
@override
@@ -68,6 +70,8 @@ class DeviceSelectorBar extends StatelessWidget {
width: width,
slideOffset: slideOffset,
isHint: swipeHint != null,
onPowerToggle: onPowerToggle,
isPoweredOn: selectedPlayer.powered,
),
],
),
+53 -12
View File
@@ -8,17 +8,20 @@ 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 = 7.0;
static const double secondaryTop = 27.0;
static const double primaryTop = 9.0;
static const double secondaryTop = 26.0;
static const double tertiaryTop = 46.0;
// 2-line layout (centered): player name, hint
static const double primaryTop2Line = 16.0;
static const double secondaryTop2Line = 36.0;
// 2-line layout (evenly spaced): player name, hint
// Height 72 / 3 = 24px spacing. Line 1 center at 24, Line 2 center at 48
static const double primaryTop2Line = 14.0; // 24 - (18/2) = 15, adjusted to 14
static const double secondaryTop2Line = 40.0; // raised slightly from 42
static const double textRightPadding = 12.0;
static const double powerButtonSize = 40.0; // Power button tap area
static const double iconSize = 28.0;
static const double iconOpacity = 0.4;
static const double secondaryTextOpacity = 0.6;
static const double primaryFontSize = 16.0;
static const double primaryFontSize = 16.0; // 3-line playing mode
static const double primaryFontSize2Line = 18.0; // larger for 2-line off mode
static const double secondaryFontSize = 14.0;
static const double tertiaryFontSize = 14.0;
static const FontWeight primaryFontWeight = FontWeight.w500;
@@ -67,6 +70,12 @@ class MiniPlayerContent extends StatelessWidget {
/// Progress value 0.0 to 1.0 (only used if showProgress is true)
final double progress;
/// Callback for power button tap (only shown in 2-line mode when provided)
final VoidCallback? onPowerToggle;
/// Whether the player is currently powered on
final bool isPoweredOn;
const MiniPlayerContent({
super.key,
required this.primaryText,
@@ -81,24 +90,35 @@ class MiniPlayerContent extends StatelessWidget {
this.isHint = false,
this.showProgress = false,
this.progress = 0.0,
this.onPowerToggle,
this.isPoweredOn = true,
});
@override
Widget build(BuildContext context) {
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
final hasTertiaryLine = tertiaryText != null && tertiaryText!.isNotEmpty;
final is2LineMode = !hasTertiaryLine;
final showPowerButton = is2LineMode && onPowerToggle != null;
final slidePixels = slideOffset * width;
// Use 3-line layout when tertiary exists, 2-line centered otherwise
final primaryTop = hasTertiaryLine ? MiniPlayerLayout.primaryTop : MiniPlayerLayout.primaryTop2Line;
final secondaryTop = hasTertiaryLine ? MiniPlayerLayout.secondaryTop : MiniPlayerLayout.secondaryTop2Line;
// Calculate text width (leaves room for right padding)
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
// Calculate right padding (extra space for power button in 2-line mode)
final rightPadding = showPowerButton
? MiniPlayerLayout.powerButtonSize + 8.0
: MiniPlayerLayout.textRightPadding;
// Darkened background for icon area (matches DeviceSelectorBar)
final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!;
// Font size for primary text (larger in 2-line mode)
final primaryFontSize = is2LineMode
? MiniPlayerLayout.primaryFontSize2Line
: MiniPlayerLayout.primaryFontSize;
return SizedBox(
width: width,
height: MiniPlayerLayout.height,
@@ -142,13 +162,13 @@ class MiniPlayerContent extends StatelessWidget {
left: MiniPlayerLayout.textLeft + slidePixels,
top: hasSecondaryLine
? primaryTop
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
right: MiniPlayerLayout.textRightPadding - slidePixels,
: (MiniPlayerLayout.height - primaryFontSize) / 2,
right: rightPadding - slidePixels,
child: Text(
primaryText,
style: TextStyle(
color: textColor,
fontSize: MiniPlayerLayout.primaryFontSize,
fontSize: primaryFontSize,
fontWeight: MiniPlayerLayout.primaryFontWeight,
),
maxLines: 1,
@@ -161,7 +181,7 @@ class MiniPlayerContent extends StatelessWidget {
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: secondaryTop,
right: MiniPlayerLayout.textRightPadding - slidePixels,
right: rightPadding - slidePixels,
child: isHint
? Row(
mainAxisSize: MainAxisSize.min,
@@ -212,6 +232,27 @@ class MiniPlayerContent extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
// Power button (only for 2-line mode when callback provided)
// Matches PlayerCard power button style
if (showPowerButton)
Positioned(
right: 8.0 - slidePixels,
top: 0,
bottom: 0,
child: Center(
child: IconButton(
onPressed: onPowerToggle,
icon: Icon(
Icons.power_settings_new_rounded,
color: isPoweredOn ? textColor : textColor.withOpacity(0.5),
size: 20,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
),
],
),
);
+173 -26
View File
@@ -1,9 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
import '../../l10n/app_localizations.dart';
import '../../services/debug_logger.dart';
import '../../services/settings_service.dart';
final _volumeLogger = DebugLogger();
/// A player card that matches the mini player's visual style.
/// Used in the player reveal overlay when swiping down or tapping the device button.
@@ -23,10 +28,23 @@ class PlayerCard extends StatefulWidget {
final VoidCallback? onSkipNext;
final VoidCallback? onPower;
final ValueChanged<double>? onVolumeChange;
final ValueChanged<bool>? onPrecisionModeChanged;
// Pastel yellow for grouped players
static const Color groupBorderColor = Color(0xFFFFF59D);
// Precision mode settings
static const int precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
static const double precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
static const double precisionSensitivity = 0.1; // 10x more precise (full swipe = 10% change)
// PERF Phase 4: Pre-cached BoxShadow to avoid allocation per frame
static const BoxShadow cardShadow = BoxShadow(
color: Color(0x33000000), // 20% black
blurRadius: 8,
offset: Offset(0, 2),
);
const PlayerCard({
super.key,
required this.player,
@@ -43,6 +61,7 @@ class PlayerCard extends StatefulWidget {
this.onSkipNext,
this.onPower,
this.onVolumeChange,
this.onPrecisionModeChanged,
});
@override
@@ -54,7 +73,70 @@ class _PlayerCardState extends State<PlayerCard> {
double _dragVolumeLevel = 0.0;
double _cardWidth = 0.0;
int _lastVolumeUpdateTime = 0;
int _lastDragEndTime = 0; // Track when last drag ended for consecutive swipes
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
static const int _precisionThrottleMs = 50; // Faster updates in precision mode
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
// Precision mode state
bool _inPrecisionMode = false;
Timer? _precisionTimer;
Offset? _lastDragPosition;
double _lastLocalX = 0.0; // Last local X position during drag (for precision mode)
bool _precisionModeEnabled = true; // From settings
double _precisionZoomCenter = 0.0; // Volume level when precision mode started
double _precisionStartX = 0.0; // Finger X position when precision mode started
@override
void initState() {
super.initState();
_loadPrecisionModeSetting();
}
Future<void> _loadPrecisionModeSetting() async {
final enabled = await SettingsService.getVolumePrecisionMode();
if (mounted) {
setState(() {
_precisionModeEnabled = enabled;
});
}
}
@override
void dispose() {
_precisionTimer?.cancel();
super.dispose();
}
void _enterPrecisionMode() {
if (_inPrecisionMode) return;
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
setState(() {
_inPrecisionMode = true;
_precisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
_precisionStartX = _lastLocalX; // Capture finger position at entry
});
widget.onPrecisionModeChanged?.call(true);
_volumeLogger.debug(
'PRECISION_MODE [${widget.player.name}]: ENTERED at ${(_precisionZoomCenter * 100).round()}%',
context: 'Volume',
);
}
void _exitPrecisionMode() {
_precisionTimer?.cancel();
_precisionTimer = null;
if (!_inPrecisionMode) return;
setState(() {
_inPrecisionMode = false;
});
widget.onPrecisionModeChanged?.call(false);
_volumeLogger.debug(
'PRECISION_MODE [${widget.player.name}]: EXITED',
context: 'Volume',
);
}
@override
Widget build(BuildContext context) {
@@ -67,7 +149,10 @@ class _PlayerCardState extends State<PlayerCard> {
final filledColor = widget.backgroundColor;
final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
return LayoutBuilder(
// RepaintBoundary isolates this card's repaint from parent transform animations
// This improves performance during the staggered reveal animation
return RepaintBoundary(
child: LayoutBuilder(
builder: (context, constraints) {
_cardWidth = constraints.maxWidth;
@@ -92,13 +177,8 @@ class _PlayerCardState extends State<PlayerCard> {
decoration: BoxDecoration(
color: unfilledColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
// PERF Phase 4: Use pre-cached static BoxShadow
boxShadow: const [PlayerCard.cardShadow],
),
clipBehavior: Clip.antiAlias,
child: Align(
@@ -133,6 +213,7 @@ class _PlayerCardState extends State<PlayerCard> {
),
);
},
),
);
}
@@ -142,13 +223,8 @@ class _PlayerCardState extends State<PlayerCard> {
decoration: BoxDecoration(
color: widget.backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
// PERF Phase 4: Use pre-cached static BoxShadow
boxShadow: const [PlayerCard.cardShadow],
),
clipBehavior: Clip.antiAlias,
child: Row(
@@ -282,25 +358,83 @@ class _PlayerCardState extends State<PlayerCard> {
}
void _onDragStart(DragStartDetails details) {
// Get current volume from player
final currentVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
// For consecutive swipes, use local volume (API may not have updated player state yet)
final now = DateTime.now().millisecondsSinceEpoch;
final timeSinceLastDrag = now - _lastDragEndTime;
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
// Use local volume if:
// 1. We have a local override (we've swiped before) AND
// 2. We're within the consecutive swipe window (5 seconds)
// Otherwise, read fresh from player state
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
final playerVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
final startVolume = useLocalVolume
? _dragVolumeLevel // Continue from where last swipe ended
: playerVolume; // Fresh from player
_volumeLogger.debug(
'DRAG_START [${widget.player.name}]: '
'useLocal=$useLocalVolume, hasOverride=$_hasLocalVolumeOverride, '
'inWindow=$isWithinWindow (${timeSinceLastDrag}ms ago), '
'localVol=${(_dragVolumeLevel * 100).round()}%, '
'playerVol=${(playerVolume * 100).round()}%, '
'startVol=${(startVolume * 100).round()}%',
context: 'Volume',
);
setState(() {
_isDraggingVolume = true;
_dragVolumeLevel = currentVolume;
_dragVolumeLevel = startVolume;
});
_lastDragPosition = details.globalPosition;
HapticFeedback.lightImpact();
}
void _onDragUpdate(DragUpdateDetails details) {
if (!_isDraggingVolume || _cardWidth <= 0) return;
// Directional volume: drag right = increase, drag left = decrease
// Distance determines amount of change (full card width = 100%)
// Multiple swipes accumulate - each starts from current volume
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / _cardWidth;
// Track local X position for precision mode
_lastLocalX = details.localPosition.dx;
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
// Check for stillness to trigger precision mode (only if enabled in settings)
final currentPosition = details.globalPosition;
if (_precisionModeEnabled && _lastDragPosition != null) {
final movement = (currentPosition - _lastDragPosition!).distance;
if (movement < PlayerCard.precisionStillnessThreshold) {
// Finger is still - start precision timer if not already running
if (_precisionTimer == null && !_inPrecisionMode) {
_precisionTimer = Timer(
Duration(milliseconds: PlayerCard.precisionTriggerMs),
_enterPrecisionMode,
);
}
} else {
// Finger moved - cancel timer (but don't exit precision mode if already in it)
_precisionTimer?.cancel();
_precisionTimer = null;
}
}
_lastDragPosition = currentPosition;
double newVolume;
if (_inPrecisionMode) {
// PRECISION MODE: Movement from entry point maps to zoomed range
// Full card width of movement = precisionSensitivity (10%) change
// e.g., at 40% center: moving full card right = 50%, full card left = 30%
final offsetX = details.localPosition.dx - _precisionStartX;
final normalizedOffset = offsetX / _cardWidth; // -1.0 to +1.0 range
final volumeChange = normalizedOffset * PlayerCard.precisionSensitivity;
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
} else {
// NORMAL MODE: Delta-based movement (full card width = 100%)
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / _cardWidth;
newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
}
// Always update visual
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
@@ -308,9 +442,10 @@ class _PlayerCardState extends State<PlayerCard> {
_dragVolumeLevel = newVolume;
});
// Throttle API calls to prevent flooding
// Throttle API calls to prevent flooding (faster in precision mode)
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastVolumeUpdateTime >= _volumeThrottleMs) {
final throttleMs = _inPrecisionMode ? _precisionThrottleMs : _volumeThrottleMs;
if (now - _lastVolumeUpdateTime >= throttleMs) {
_lastVolumeUpdateTime = now;
widget.onVolumeChange?.call(newVolume);
}
@@ -319,7 +454,17 @@ class _PlayerCardState extends State<PlayerCard> {
void _onDragEnd(DragEndDetails details) {
// Send final volume on release
_volumeLogger.debug(
'DRAG_END [${widget.player.name}]: '
'finalVol=${(_dragVolumeLevel * 100).round()}%, '
'precisionMode=$_inPrecisionMode',
context: 'Volume',
);
widget.onVolumeChange?.call(_dragVolumeLevel);
_lastDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
_exitPrecisionMode();
_lastDragPosition = null;
setState(() {
_isDraggingVolume = false;
});
@@ -327,6 +472,8 @@ class _PlayerCardState extends State<PlayerCard> {
}
void _onDragCancel() {
_exitPrecisionMode();
_lastDragPosition = null;
setState(() {
_isDraggingVolume = false;
});
+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,
});
}
+700 -127
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,151 +39,713 @@ class QueuePanel extends StatelessWidget {
required this.topPadding,
required this.onClose,
required this.onRefresh,
this.onDraggingChanged,
this.onSwipeStart,
this.onSwipeUpdate,
this.onSwipeEnd,
});
@override
Widget build(BuildContext context) {
return Container(
color: backgroundColor,
child: Column(
children: [
// Header
Container(
padding: EdgeInsets.only(top: topPadding + 4, left: 4, right: 16),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back_rounded, color: textColor, size: IconSizes.md),
onPressed: onClose,
padding: Spacing.paddingAll12,
),
const Spacer(),
Text(
'Queue',
style: TextStyle(
color: textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: Icon(Icons.refresh_rounded, color: textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: onRefresh,
padding: Spacing.paddingAll12,
),
],
),
),
State<QueuePanel> createState() => _QueuePanelState();
}
// Queue content
Expanded(
child: isLoading
? Center(child: CircularProgressIndicator(color: primaryColor))
: queue == null || queue!.items.isEmpty
? _buildEmptyState(context)
: _buildQueueList(),
),
],
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 {
// Remove from local list - Dismissible handles the animation
setState(() {
_items.removeAt(index);
});
// Call API
final playerId = widget.queue?.playerId;
if (playerId != null) {
widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId);
}
}
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: (_) {},
),
);
}
Widget _buildEmptyState(BuildContext context) {
return EmptyState.queue(context: context);
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');
} catch (e) {
debugPrint('QueuePanel: Move API error: $e');
}
} else {
debugPrint('QueuePanel: playerId is null, cannot move');
}
// Allow updates again after a delay for server state to propagate
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
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) {
// 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: widget.topPadding + 4, left: 4, right: 16),
child: Row(
children: [
IconButton(
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: widget.textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
// Clear queue button
IconButton(
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: _handleClearQueue,
padding: Spacing.paddingAll12,
tooltip: 'Clear queue',
),
],
),
),
// Queue content
Expanded(
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 _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 RepaintBoundary(
child: Opacity(
opacity: isPastItem ? 0.5 : 1.0,
child: Container(
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
decoration: BoxDecoration(
color: isCurrentItem ? primaryColor.withOpacity(0.15) : Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
),
child: ListTile(
dense: true,
leading: ClipRRect(
borderRadius: BorderRadius.circular(Radii.sm),
child: SizedBox(
width: 44,
height: 44,
child: imageUrl != null
? 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),
),
errorWidget: (context, url, error) => Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
),
)
: Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
),
),
),
title: Text(
item.track.name,
style: TextStyle(
color: isCurrentItem ? primaryColor : textColor,
fontSize: 14,
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
? Text(
item.track.artists!.first.name,
style: TextStyle(
color: 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
},
),
),
),
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: isCurrentItem ? const EdgeInsets.symmetric(horizontal: 8, vertical: 2) : EdgeInsets.zero,
decoration: BoxDecoration(
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(
width: 44,
height: 44,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 176,
memCacheHeight: 176,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
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: widget.textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
),
)
: Container(
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 ? widget.primaryColor : widget.textColor,
fontSize: 14,
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
? Text(
item.track.artists!.first.name,
style: TextStyle(
color: widget.textColor.withOpacity(0.6),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
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,
provider: playlist.provider,
itemId: playlist.itemId,
),
),
);
},
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),
),
),
],
),
),
);
}
}
+160
View File
@@ -0,0 +1,160 @@
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 '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;
@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);
}
}
}
@override
Widget build(BuildContext context) {
super.build(context);
// Don't render if empty and done loading
if (_playlists.isEmpty && !_isLoading) {
return const SizedBox.shrink();
}
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Calculate card dimensions
// Default: 44 for title, remaining for content (artwork + 2 lines of text)
final rowHeight = widget.rowHeight ?? 237.0;
final titleHeight = 44.0;
final contentHeight = rowHeight - titleHeight;
// Artwork is square, text takes ~50px (8 padding + title + owner)
final artworkSize = contentHeight - 50;
final cardWidth = artworkSize;
return RepaintBoundary(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section title
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text(
widget.title,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Horizontal list of playlists
SizedBox(
height: contentHeight,
child: _isLoading && _playlists.isEmpty
? Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
)
: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _playlists.length,
cacheExtent: 500,
addAutomaticKeepAlives: false,
itemBuilder: (context, index) {
final playlist = _playlists[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: SizedBox(
width: cardWidth,
child: PlaylistCard(
playlist: playlist,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
),
),
);
},
),
),
],
),
);
}
}
+105
View File
@@ -0,0 +1,105 @@
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 - centered for circular images
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,
),
),
),
),
],
),
),
);
}
}
+160
View File
@@ -0,0 +1,160 @@
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 '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;
@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);
}
}
}
@override
Widget build(BuildContext context) {
super.build(context);
// Don't render if empty and done loading
if (_radioStations.isEmpty && !_isLoading) {
return const SizedBox.shrink();
}
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Calculate card dimensions - similar to ArtistRow for circular items
// Default: 44 for title, remaining for content (circular image + name)
final rowHeight = widget.rowHeight ?? 207.0;
final titleHeight = 44.0;
final contentHeight = rowHeight - titleHeight;
// Circular artwork, text takes ~40px (8 padding + name lines)
final artworkSize = contentHeight - 40;
final cardWidth = artworkSize;
return RepaintBoundary(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section title
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text(
widget.title,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Horizontal list of radio stations
SizedBox(
height: contentHeight,
child: _isLoading && _radioStations.isEmpty
? Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
)
: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _radioStations.length,
cacheExtent: 500,
addAutomaticKeepAlives: false,
itemBuilder: (context, index) {
final station = _radioStations[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: SizedBox(
width: cardWidth,
child: RadioStationCard(
radioStation: station,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
),
),
);
},
),
),
],
),
);
}
}
+116 -123
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;
});
_hasLoaded = true;
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,49 +314,14 @@ 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),
),
),
),
);
},
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildCoverGrid(),
// RepaintBoundary caches the rendered grid for smooth animation
child: RepaintBoundary(
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(12), // Match detail screen
child: _buildCoverGrid(),
),
),
),
),
+101 -69
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;
});
_hasLoaded = true;
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),
),
],
),
+344 -88
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,118 +180,262 @@ 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,
currentVolume < 0.01
? Icons.volume_off
: currentVolume < 0.3
? Icons.volume_down
: Icons.volume_up,
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: Icon(
isMuted
? Icons.volume_off
: currentVolume < 0.3
? Icons.volume_down
: Icons.volume_up,
icon: const Icon(
Icons.volume_down,
color: Colors.white70,
),
onPressed: () async {
try {
await maProvider.setMute(player.playerId, !isMuted);
} catch (e) {
// Error already logged by provider
}
},
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, -1),
),
// Slider with floating teardrop indicator
Expanded(
child: SizedBox(
height: 48, // Increase touch target height
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white.withOpacity(0.3),
thumbColor: Colors.white,
overlayColor: Colors.white.withOpacity(0.2),
),
child: Slider(
value: currentVolume.clamp(0.0, 1.0),
onChanged: (value) {
setState(() {
_pendingVolume = _isLocalPlayer ? value : value * 100;
});
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: _inPrecisionMode ? accentColor : Colors.white,
inactiveTrackColor: Colors.white.withOpacity(0.3),
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: (_) {},
),
),
),
),
// 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(),
),
),
),
],
);
},
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;
});
}
}
}
},
),
),
),
),
SizedBox(
width: 40,
child: Text(
'${(currentVolume * 100).round()}%',
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
textAlign: TextAlign.center,
// Volume increase button
IconButton(
icon: const Icon(
Icons.volume_up,
color: Colors.white70,
),
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.7.4-beta+36
version: 2.8.6-beta+44
environment:
sdk: '>=3.0.0 <4.0.0'